featured.svg

CPUPPUJoypadMapperAPU——五個獨立的 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-cpunes-ppunes-apunes-joypadnes-mapper。前端框架:Bevy 0.18 + bevy_egui + cpal + planus(FlatBuffers)。

系統架構

所有模擬元件透過 nes-apuSystem struct 串接在一起——它擁有 CPU、PPU、APU、Joypad 的所有權,負責它們之間的 cycle-level 同步:

graph TB subgraph "Bevy App" MAIN[Main Loop
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 的限制裡,真的榨出了不可思議的東西。

參考資源