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)座標から計算する。(とりあえずスクロール値無視)
NTは8pixel単位、ATTRは32pixel単位で管理されているので
アドレスは割り算をしなくてもxとyのいくつかのビットを判定して
ベースアドレスにオフセットを足すだけで計算できる。(はず)
NT byte
0x48なので「H」のスプライト。
Charactor romを参照して、座標に対応するNT byteを得る。
AT byte
色
PPUが内部で持つ色テーブルは以下で出てきたのをそのまま使っている。
ファミコンエミュレータの創り方 - Hello, World!編 - - Qiita
描写
NT byteからの値と、AT byteからの値をセレクタとして、パレットから色番号を得る。
さらに色番号から固定の色テーブルを参照して、やっと実際のRGB値を得て色を描写できる。
「x」はいつも0x00で背景色を出力することになる。
背景色は0x3F00の色番号。
HelloWorldと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を実現したい。)