從 CPU、PPU、Joypad、Mapper 到 APU——五個獨立的 crate,各自負責 NES 硬體的一個部分。現在是把它們全部接在一起的時候了。
這個專案是 NES 模擬器的前端——用 Bevy 做渲染、cpal 做音訊輸出、egui 做 debug UI,加上一個 CRT post-processing shader 讓畫面看起來像真的接在老電視上。整個前端不到 2600 行(含自動生成的 FlatBuffer schema),但麻雀雖小,五臟俱全:即時遊玩、存檔讀檔、CPU 反組譯、PPU 調色盤檢視、APU 波形顯示、手把視覺化——全都有。
專案結構
nes-emu/
├── Cargo.toml
└── src/
├── main.rs # Bevy app 設定(58 行)
├── emulation.rs # 模擬核心:主迴圈、時序同步(176 行)
├── video.rs # Framebuffer → GPU 材質(64 行)
├── audio.rs # cpal 音訊串流(53 行)
├── input.rs # 鍵盤/手把輸入對應(97 行)
├── crt.rs # CRT shader 材質定義(61 行)
├── debug_ui.rs # egui 除錯面板(747 行)
├── save_state.rs # FlatBuffer 存檔/讀檔(113 行)
├── generated/ # Planus 自動生成的 schema(1226 行)
└── assets/
└── shaders/
└── crt.wgsl # CRT 後處理 shader(84 行)
依賴的五個 NES crate:nes-cpu、nes-ppu、nes-apu、nes-joypad、nes-mapper。前端框架:Bevy 0.18 + bevy_egui + cpal + planus(FlatBuffers)。
系統架構
所有模擬元件透過 nes-apu 的 System struct 串接在一起——它擁有 CPU、PPU、APU、Joypad 的所有權,負責它們之間的 cycle-level 同步:
60 FPS] --> EMU[Emulation System] MAIN --> VID[Video System] MAIN --> AUD[Audio System] MAIN --> INP[Input System] MAIN --> DBG[Debug UI] end subgraph "NES System" EMU --> SYS[nes_apu::System] SYS --> CPU[CPU
6502] SYS --> PPU[PPU
Framebuffer] SYS --> APU[APU
5 Channels] SYS --> JOY[Joypad
2 Players] CPU --> MAP[Mapper
Bank Switch] PPU --> MAP end PPU -- "256×240 pixels" --> VID APU -- "PCM samples" --> AUD INP -- "button state" --> JOY
CPU 和 PPU 共享 Mapper(透過 Rc<RefCell<>>),APU 的 DMC 也需要存取 CPU 的記憶體匯流排來讀取 sample data。這種多方共享的擁有權結構在 Rust 裡用 interior mutability 來處理——Rc<RefCell<>> 在單執行緒的模擬器裡是最自然的選擇。
主迴圈:Accumulator-Based Timing
模擬器的時序和螢幕的更新率是解耦的——用 accumulator 模式確保不管你的螢幕是 60Hz、144Hz 還是不穩定的幀率,NES 都以精確的 NTSC 速度運行:
const NTSC_FRAME_SECS: f64 = 1.0 / 60.0988;
pub fn run_emulation_frame(
mut nes: NonSendMut<NesSystem>,
time: Res<Time>,
audio_buf: Res<AudioBuffer>,
// ...
) {
if nes.paused {
nes.accumulator = 0.0;
return;
}
nes.accumulator += time.delta_secs_f64();
// 防止 spiral of death:最多追趕 4 幀
nes.accumulator = nes.accumulator.min(NTSC_FRAME_SECS * 4.0);
let mut frames_run = 0;
while nes.accumulator >= NTSC_FRAME_SECS {
nes.sys.joypad1.borrow_mut().set_buttons(nes_input.0);
nes.sys.run_until_frame();
// 把 APU 產生的 sample 送到音訊 ring buffer
let samples = nes.sys.apu.borrow_mut().drain_samples();
if let Ok(mut buf) = audio_buf.buffer.lock() {
if buf.len() < 8192 {
buf.extend(samples.iter());
}
}
nes.accumulator -= NTSC_FRAME_SECS;
frames_run += 1;
}
if frames_run > 0 {
// 把 PPU framebuffer 複製到 GPU 材質
copy_framebuffer_to_texture(&nes, &mut images, &fb_handle);
}
}幾個關鍵設計:
- 4 幀上限:防止 alt-tab 回來後模擬器瘋狂追趕累積的時間
- 暫停時歸零:避免取消暫停瞬間跑一大堆幀
- 只在有新幀時更新材質:避免無意義的 GPU 上傳
NonSendMut:Rust 所有權的巧妙運用
NesSystem 裡面有 Rc<RefCell<>>——這些型別不是 Send(不能跨執行緒傳遞)。Bevy 預設會在多個執行緒上排程系統,所以我們用 NonSendMut<NesSystem> 告訴 Bevy:「這個資源只能在主執行緒上存取。」
pub fn setup_emulation(world: &mut World) {
// exclusive world access,只在初始化時呼叫一次
let sys = System::new(rom, sample_rate);
world.insert_non_send_resource(NesSystem {
sys,
paused: false,
accumulator: 0.0,
});
}這是一個很實用的 pattern——當你的核心邏輯本質上是單執行緒的(模擬器幾乎都是),不需要硬把它改成 thread-safe,只要告訴框架「我知道這不能跨執行緒,請安排在主執行緒跑」就好。
影像:從 Framebuffer 到 CRT
Framebuffer 上傳
PPU 每幀輸出 256×240 個 pixel(每個 pixel 是 NES palette 的一個顏色值),前端把它轉成 RGBA8 上傳到 GPU:
fn copy_framebuffer_to_texture(nes: &NesSystem, images: &mut Assets<Image>, handle: &FramebufferHandle) {
let ppu = nes.sys.ppu.borrow();
let fb = ppu.framebuffer();
if let Some(image) = images.get_mut(&handle.0) {
for (i, &color) in fb.iter().enumerate() {
let (r, g, b) = PALETTE[color as usize];
image.data[i * 4] = r;
image.data[i * 4 + 1] = g;
image.data[i * 4 + 2] = b;
image.data[i * 4 + 3] = 255;
}
}
}256×240×4 = 245,760 bytes per frame——不到 250KB,完全不是瓶頸。
CRT 後處理 Shader
CRT shader 是整個專案最有趣的視覺效果——84 行 WGSL 程式碼模擬老式 CRT 電視的四種特效:
1. Barrel Distortion(桶狀扭曲)
真的 CRT 螢幕是微微凸出的,畫面邊緣會向外彎:
fn barrel_distort(uv: vec2<f32>, curvature: f32) -> vec2<f32> {
let centered = uv - 0.5;
let dist = dot(centered, centered);
let distorted = centered * (1.0 + dist * curvature);
return distorted + 0.5;
}原理是把 UV 座標從中心算距離,距離越遠的像素被推得越遠——產生凸面鏡的效果。
2. Scanlines(掃描線)
CRT 的電子槍一行一行掃描,行與行之間有暗線:
let scanline = 1.0 - scanline_intensity * 0.5 * (1.0 - cos(uv.y * 240.0 * 3.14159));用 cos(y × 240 × π) 產生 240 條暗帶,剛好對齊 NES 的 240 條掃描線。
3. Shadow Mask(蔭罩)
真的 CRT 每個「像素」其實是 RGB 三個磷光粉點,相鄰像素的 R/G/B 交錯排列:
let mask_pos = i32(uv.x * 768.0) % 3;
var mask = vec3<f32>(0.8, 0.8, 0.8);
if mask_pos == 0 { mask.r = 1.0; }
else if mask_pos == 1 { mask.g = 1.0; }
else { mask.b = 1.0; }每 3 個水平像素循環一次 R-G-B 增亮,模擬磷光粉的排列。
4. Vignette(暗角)
CRT 螢幕邊緣比中心暗:
let vignette = 1.0 - vignette_intensity * dot(centered, centered) * 2.0;距離中心越遠,亮度衰減越多。
三種顯示模式可以在 debug UI 即時切換:Nearest(像素完美)、Linear(雙線性)、CRT(完整後處理)。
音訊:跨執行緒 Ring Buffer
音訊是模擬器裡最需要即時性的部分——如果 buffer underflow,你會聽到爆音。架構上用 Arc<Mutex<VecDeque<f32>>> 做 producer-consumer:
模擬執行緒(producer) cpal 音訊執行緒(consumer)
│ │
│ push samples │ pop samples
└──────→ Ring Buffer ←─────┘
(VecDeque)
max 8192
pub fn setup_audio(mut commands: Commands) {
let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(4096)));
let buf_clone = buffer.clone();
let stream = device.build_output_stream(
&config,
move |data: &mut [f32], _| {
let mut buf = buf_clone.lock().unwrap();
for frame in data.chunks_mut(channels) {
let sample = buf.pop_front().unwrap_or(0.0);
for ch in frame.iter_mut() {
*ch = sample;
}
}
},
|err| eprintln!("Audio error: {err}"),
None,
).unwrap();
// ...
}- 模擬端每幀 drain APU 的 sample(~735 samples @ 44100Hz / 60fps)
- 音訊端每次 callback pop 需要的量
- 8192 上限防止跑太快時記憶體無限增長
- Underflow 靜默填 0.0——比爆音好
輸入:鍵盤 + 手把
NES 的 joypad 就是 8 個按鈕,用一個 u8 bitfield 就能完整表示:
pub fn read_input(
keyboard: Res<ButtonInput<KeyCode>>,
gamepads: Query<&Gamepad>,
mut nes_input: ResMut<NesInput>,
) {
let mut state = 0u8;
// 鍵盤
if keyboard.pressed(KeyCode::ArrowUp) { state |= 1 << 4; }
if keyboard.pressed(KeyCode::ArrowDown) { state |= 1 << 5; }
if keyboard.pressed(KeyCode::KeyZ) { state |= 1 << 0; } // A
if keyboard.pressed(KeyCode::KeyX) { state |= 1 << 1; } // B
// 手把(第一支)
if let Some(gamepad) = gamepads.iter().next() {
if gamepad.pressed(GamepadButton::DPadUp) { state |= 1 << 4; }
// 類比搖桿 → 方向鍵(deadzone 0.5)
if let Some(axis) = gamepad.get(GamepadAxis::LeftStickY) {
if axis > 0.5 { state |= 1 << 4; } // up
if axis < -0.5 { state |= 1 << 5; } // down
}
// ...
}
nes_input.0 = state;
}每幀讀一次,bitfield OR 合併鍵盤和手把的狀態——兩個輸入來源可以同時使用,不衝突。
Debug UI:747 行的除錯面板
Debug UI 是這個前端最大的檔案——它在畫面右側開一個 250px 的 egui 面板,塞滿了各種即時資訊:
CPU 反組譯器
暫停時顯示 PC 附近的 6 條指令,每條顯示十六進位機器碼和助記符:
$8000: A9 10 LDA #$10
$8002: 8D 00 20 STA $2000 ← PC
$8005: A9 3F LDA #$3F
加上暫存器狀態(A、X、Y、PC、SP)和 status flags(N、V、B、D、I、Z、C),每個 flag 用顏色標示 on/off。
PPU 調色盤檢視
即時顯示 NES 的 8 組子調色盤(4 組背景 + 4 組精靈),每組 4 個顏色方塊:
BG0: ██ ██ ██ ██ BG1: ██ ██ ██ ██
BG2: ██ ██ ██ ██ BG3: ██ ██ ██ ██
SP0: ██ ██ ██ ██ SP1: ██ ██ ██ ██
SP2: ██ ██ ██ ██ SP3: ██ ██ ██ ██
配合捲軸位置(X, Y, nametable)、rendering flag、精靈統計(數量、8×8 或 8×16)。
APU 波形顯示
用 egui_plot 即時繪製 5 個聲道的波形——Pulse 1(綠)、Pulse 2(藍)、Triangle(橘)、Noise(紫)、DMC(紅),每個聲道顯示最近 256 個 sample 的 ring buffer 資料。
手把視覺化
畫面上顯示一個 NES 手把的圖示,按下的按鈕會變紅——在 debug 輸入問題時非常直觀。
存檔 / 讀檔:FlatBuffers
Save state 用 FlatBuffers(透過 Rust 的 Planus 實作)序列化整個模擬器狀態:
pub fn save(nes: &NesSystem, path: &Path) -> Result<Vec<u8>> {
let mut cpu_state = Vec::new();
nes.sys.cpu.save_state(&mut cpu_state);
let mut ppu_state = Vec::new();
nes.sys.ppu.borrow().save_state(&mut ppu_state);
let mut apu_state = Vec::new();
nes.sys.apu.borrow().save_state(&mut apu_state);
// ... mapper, joypad ...
let state = NesState {
version: 2,
cpu: &cpu_state,
ram: &ram_data,
mapper: &mapper_state,
ppu: &ppu_state,
apu: &apu_state,
// ...
};
let mut builder = planus::Builder::new();
let offset = state.prepare(&mut builder);
builder.finish(offset, None);
Ok(builder.as_slice().to_vec())
}FlatBuffers 的好處是 zero-copy deserialization——讀檔時不需要把整個 blob 解碼成 Rust struct,可以直接從 buffer 讀取欄位。對於 PPU 的 VRAM(幾 KB)和 APU 的狀態這種大小的資料,效能差異不大,但序列化格式本身很緊湊。
存檔策略:
- F5 存檔:序列化後同時寫入磁碟(
{rom名}.sav)和記憶體快取 - F7 讀檔:優先從記憶體讀取,沒有再從磁碟讀
- 一個 slot:簡單粗暴,一個存檔位就夠
快捷鍵
| 按鍵 | 功能 |
|---|---|
| 方向鍵 | D-pad |
| Z / X | A / B 按鈕 |
| A / S | Select / Start |
| Esc | 暫停 / 繼續 |
| F3 | 顯示 / 隱藏 debug 面板 |
| F5 | 存檔 |
| F7 | 讀檔 |
| F12 | 截圖(PNG,含時間戳) |
總結
| 模組 | 行數 | 負責 |
|---|---|---|
| main.rs | 58 | Bevy plugin 註冊 |
| emulation.rs | 176 | 主迴圈、時序同步 |
| video.rs | 64 | Framebuffer → GPU |
| audio.rs | 53 | cpal 音訊串流 |
| input.rs | 97 | 鍵盤/手把對應 |
| crt.rs | 61 | CRT shader 材質 |
| debug_ui.rs | 747 | egui 除錯面板 |
| save_state.rs | 113 | FlatBuffer 存檔 |
| crt.wgsl | 84 | CRT 後處理 shader |
整個 NES 模擬器系列到這裡告一段落。回顧一下各個零件:
| Crate | 負責 | 文章 |
|---|---|---|
| nes-cpu | 6502 CPU + 記憶體匯流排 | CPU 文章 |
| nes-ppu | 圖形處理器 + 256×240 渲染 | PPU 文章 |
| nes-joypad | 手把串列輸入 | Joypad 文章 |
| nes-mapper | 卡匣 bank switching | Mapper 文章 |
| nes-apu | 音效處理器 + 5 聲道混音 | APU 文章 |
| nes-emu | 前端整合 | 本文 |
六個 crate、六篇文章、幾千行 Rust——從單一指令的 fetch-decode-execute,到像素一條一條掃出來,到方波三角波噪音混在一起,到卡匣的 bank 一塊一塊切換,最後全部透過 Bevy 顯示在螢幕上、cpal 從喇叭放出來。NES 的硬體設計在 1983 年是工程奇蹟,用現代語言重新實作一遍,你會更深刻地體會到——那些工程師在 8-bit 的限制裡,真的榨出了不可思議的東西。
參考資源
- Bevy Engine — Rust 遊戲引擎
- cpal — Cross-platform 音訊函式庫
- egui — Immediate mode GUI
- FlatBuffers — 高效序列化格式
- Planus — Rust FlatBuffers 實作
- nesdev.org — NES 開發者社群與技術文件