e-tipsmemo

ごった煮

NES Emulator in Rust : 3 PPU Background

e-tipsmemo.hatenablog.com
続き

PPUを始めるにあたってnestestの表示または、HelloWorldの表示が最初の目標となる。
ファミコンエミュレータの創り方 - Hello, World!編 - - Qiita
スプライト表示なし。(スクロールなしの)バックグラウンドを描写さえすれば動く。

PPU

まずCPUと同じようにPPUのレジスタを定義しておく。
内部にVRAMを持っていたりするのでよしなに定義しておく。

backgroundに最低限必要なのは、chrom, vram, palette, addr, ctrl, latch, imgあたり?とそれらを読み書きするメモリアクセスの関数。

pub struct Ppu {
    pub chrrom: [u8; 0x2000], //readonly
    pub vram: Cell<[u8; 0x1000]>,
    pub oam: Cell<[u8; 0x0100]>,
    pub palette: Cell<[u8; 0x0020]>,
    pub addr: Cell<u16>,
    pub ctrl: Cell<PpuCtrl>,
    pub mask: Cell<PpuMask>,
    pub status: Cell<PpuStatus>,
    pub oamaddr: Cell<u8>,
    pub scroll: Cell<u16>,
    pub latch: Cell<bool>,
    pub img: RefCell<image::ImageBuffer<image::Rgba<u8>, Vec<u8>>>,

    pub dly_data: Cell<u8>,
    secondary_oam: Cell<[u8; 32]>, //4byte * 8 sprites
    sprite_zero_flag: Cell<[bool; 8]>,

    //for debue
    pub grid_on: Cell<bool>,
}

chromeは起動時にromからキャラクターROMをコピーしてくる。
imgはpistonの描写ループでborrowしたいので、RefCellになっている。
(Cellだとgetしかできなかった気がする。Copy Traitが必要となるがImageBufferには実装されていない)
VBlankが立つときにのみ、PPUの描写関数が持つ画面バッファをcloneして代入する。

タイミング

参考サイトを元に、
以前書いた
NES Emulator in Rust : 1 Operation Cycle - e-tipsmemo
の、CPU 1サイクル後に、実行されるPPU 3サイクルで呼ばれるGeneratorの関数内部で描写を行う。
(参考サイトはここら辺から何も書いていない上に、githubのPPUは不完全な実装となっている)
ここの内部で、スキャンラインとx方向をカウントして、描写エリア内で、VRAMを読んでPPUの1サイクルごとにピクセルを置いていく。

pub fn run<'a>(nes: &'a Nes) -> impl Generator<Yield = PpuStep, Return = !> + 'a {
	let mut sprite_evaluation = Ppu::sprite_evaluation(nes);
	let mut run_renderer = Ppu::renderer(nes);

	move || loop {
		loop {
			match Pin::new(&mut sprite_evaluation).resume(()) {
				GeneratorState::Yielded(PpuStep::Cycle) => {
					break;
				}
				GeneratorState::Yielded(step) => {
					yield step;
				}
			}
		}
		loop {
			match Pin::new(&mut run_renderer).resume(()) {
				GeneratorState::Yielded(PpuStep::Cycle) => {
					break;
				}
				GeneratorState::Yielded(step) => {
					yield step;
				}
			}
		}
		yield PpuStep::Cycle;
	}
}

今回はbackgroundだけなので、sprite_evalutateはいらない。
rendererの中で、scanlineをカウントアップし、そのscanline loopの中で、341個のyieldを入れることで、x方向のサイクルを消費する。
https://wiki.nesdev.com/w/images/d/d1/Ntsc_timing.png
をもとに、scanline = 240, 261, x = 0の時の処理として、
最低限背景描写に必要ないくつかのフラグの処理をしておく。(VBlank)
(厳密にはx=1サイクル目だけどnestestやhelloworldには影響がない)

    fn renderer<'a>(nes: &'a Nes) -> impl Generator<Yield = PpuStep, Return = !> + 'a {
        move || loop {
            let mut buf =
                image::ImageBuffer::new(cparams::VFRAME_W as u32, cparams::VFRAME_H as u32);
            for frame in 0.. {
                for scanline in 0..cparams::FRAME_H {

                    //略

                    if scanline == 240 {
                        nes.ppu.status.update(|mut status| {
                            status.set(PpuStatus::VBLANK_STARTED, true);
                            status
                        });

                        let ctrl = nes.ppu.ctrl.get();
                        if ctrl.contains(PpuCtrl::VBLANK_INTERRUPT) {
                            nes.cpu.nmi.set(true);
                        }

                        *nes.ppu.img.borrow_mut() = buf.clone();
                        yield PpuStep::Vblank;
                    } else if y == 261 {
                        //Pre-render line
                        nes.ppu.status.update(|mut status| {
                            status.set(PpuStatus::VBLANK_STARTED, false);
                            status.set(PpuStatus::ZERO_HIT, false);
                            status
                        });
                        buf = image::ImageBuffer::new(
                            cparams::VFRAME_W as u32,
                            cparams::VFRAME_H as u32,
                        );
                    }

                    //略

この後に 341回分のyieldがある。

描写

x が 8の倍数の時(0, 8, 16, ...)、そこから8ピクセル分の色を一気に計算して、
そのあと、yield を8回loopさせて、1ピクセルごとに置くことにした。

for dx in 0..8 {
	let x = tile_x * 8 + dx;
	let y = scanline;

	//Name tableとAttr Tableからbgのカラーインデックを計算
	//略

	bg_color_index_array[dx as usize] = color_index;
}

for dx in 0usize..8 {
	let x = (tile_x * 8) as u32 + dx as u32;
	let bg_color_index = bg_color_index_array[dx];

	
	let color = cparams::COLORS[bg_color_index];

	let color = if nes.ppu.grid_on.get() {
		if (x + 1) % 8 == 0 || (y + 1) % 8 == 0 {
			[0, 0, 255]
		} else {
			color
		}
	} else {
		color
	};

	let r = color[0];
	let g = color[1];
	let b = color[2];
	let a = 255;
	let pixel = image::Rgba([r, g, b, a]);
	buf.put_pixel(x, y as u32, pixel);
	yield PpuStep::Cycle;
}

上と下のループを一緒にしてもよかったかもしれない。(なぜこうしたのかは忘れた。)
やる気が出ればそうする。

Name TableとAttribute Tables

Name Tableからの情報が、Sprite indexを示して、
Attribute Tableからの情報が、Palette indexを示している。
読みだすNTアドレスとATTRアドレスはスクリーン上の(x, y)座標から計算する。(とりあえずスクロール値無視)

f:id:katakanan:20210829163310p:plainf:id:katakanan:20210829161723p:plain

NTは8pixel単位、ATTRは32pixel単位で管理されているので
アドレスは割り算をしなくてもxとyのいくつかのビットを判定して
ベースアドレスにオフセットを足すだけで計算できる。(はず)

NT byte

0x48なので「H」のスプライト。
f:id:katakanan:20210829163341p:plain
Charactor romを参照して、座標に対応するNT byteを得る。
f:id:katakanan:20210829180401p:plain

AT byte

f:id:katakanan:20210829180606p:plain

PPUが内部で持つ色テーブルは以下で出てきたのをそのまま使っている。
ファミコンエミュレータの創り方 - Hello, World!編 - - Qiita

描写

f:id:katakanan:20210829180705p:plain
NT byteからの値と、AT byteからの値をセレクタとして、パレットから色番号を得る。
さらに色番号から固定の色テーブルを参照して、やっと実際のRGB値を得て色を描写できる。

「x」はいつも0x00で背景色を出力することになる。
背景色は0x3F00の色番号。

HelloWorldとNestest

そしてようやく

f:id:katakanan:20210722220850p:plain:w300f:id:katakanan:20210722201831p:plain
まだ入力を実装していない(ことになっている)ので、nestestを実行できない。(実は実装してある)

スクロールはアドレスを求めるxとyにscroll_xとscroo_yを足して、NTとATのアドレスを計算するときに

  • PPUCTRLレジスタの下位2ビット
  • 画面をまたぐ
  • 垂直・水平ミラー

あたりを考慮することになる。がまた別で記事を書く気がする。

感想

ここら辺からRustのCargo.tomlで、opt-level = 2 を指定しないと60fpsが維持できなくなってきた。

実際のPPUは、この画像だと2タイル先読み(16pixel分)して、(例:タイル0とタイル1)
16pixelの情報を1 PPU Cycleずつシフトしながら出力していき、8pixel(タイル0)描写中に、タイル2のNTとATをfetch/decodeしていき、
タイル0の描写分が終わったタイミングではタイル2の8pixelが準備完了する。というようにパイプライン化されている。
(このエミュレータ今のところはそうなってはいない。)
ATやNTを読むタイミングが16 PPU Cycleだけ遅いので、もし描写中にVRAMを書き換えるようなゲームがあると正しく動かない可能性がある。
(やる気がでたらNTSC_Timingを実現したい。)