featured.svg

寫完 NES 模擬器之後,我一直在想一件事:Bevy 用起來跟我過去用過的任何遊戲引擎都不一樣。不是 API 風格的差異——是思考方式根本不同。

用 Unity 寫 C#,你把腳本掛到 GameObject 上,裡面 GetComponent<T>() 隨便抓、隨便改。用 Godot 寫 GDScript,一切都是 Node 樹,$Child.property = value 直覺到不行。但 Bevy?你連「把兩個系統同時存取同一個資源」都得在型別層面交代清楚,不然編譯器直接擋下來。

這不是 Bevy 故意刁難你。是 Rust 的所有權系統逼著 Bevy 必須這樣設計——而這個「被逼的」結果,意外地產出了一個非常乾淨的架構。

這篇文章用 NES 模擬器前端(~2600 行 Rust)作為實例,聊聊 Rust 的所有權、借用和型別系統是怎麼一路影響 Bevy 的 ECS 設計,以及在實際專案裡會碰到哪些有趣的 pattern。

ECS 不只是 Pattern,是 Rust 的必然

傳統 OOP 遊戲引擎的痛

在傳統 OOP 引擎裡,物件之間到處互相引用:

Player → Weapon → Inventory → Player (循環引用)
Enemy → Player (觀察目標)
GameManager → Player, Enemy, UI, ... (上帝物件)

C# 和 Java 有 GC 幫你收拾,循環引用不是問題。但 Rust 沒有 GC,Player 不能隨便持有 &mut Weapon 的引用——因為 Rust 的借用規則說:同一時間只能有一個可變引用

所以你如果在 Rust 裡用 OOP 寫遊戲引擎,馬上就會遇到一堆 lifetime 地獄:

struct Player<'a> {
    weapon: &'a mut Weapon,  // ← lifetime 要標
}

struct World<'a> {
    player: Player<'a>,
    weapons: Vec<Weapon>,     // ← player.weapon 借自這裡?
    // 🔴 weapons 和 player 不能同時存在於 World 裡
}

ECS 的出現剛好解決了這個問題——不是因為它是什麼偉大的設計理念,而是因為它天然地符合 Rust 的所有權模型

ECS 如何繞開所有權地獄

ECS 的資料佈局長這樣:

World
├── 實體表 (Entity): [0, 1, 2, ...]
├── 元件儲存 (Components):
│   ├── Position:  [Some(pos0), None, Some(pos2), ...]
│   ├── Velocity:  [Some(vel0), None, None, ...]
│   └── Health:    [None, Some(hp1), Some(hp2), ...]
└── 資源 (Resources):
    ├── Time
    ├── InputState
    └── ...

每種元件是一個獨立的陣列。系統(System)透過 Query 宣告它需要哪些元件的存取權限:

fn movement_system(mut query: Query<(&mut Position, &Velocity)>) {
    for (mut pos, vel) in &mut query {
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

對 Rust 來說,這個 Query 等於是說「我要可變借用 Position 陣列,同時不可變借用 Velocity 陣列」——沒有重疊,沒有衝突,borrow checker 開心,世界和平。

Bevy 的排程器利用這些 Query 的存取宣告來自動判斷哪些系統可以平行執行。如果系統 A 只讀 Position,系統 B 只寫 Velocity,它們不衝突,可以丟到不同執行緒上跑。

這就是 Rust 的所有權模型帶來的附加價值:你為了滿足編譯器而寫的型別宣告,同時也成了排程器的平行化依據。

當所有權撞上硬體模擬

NES 的硬體元件之間有大量的共享狀態——CPU 要讀寫 PPU 的暫存器、APU 的暫存器、Joypad 的暫存器,PPU 和 CPU 共用同一個 Mapper(卡匣晶片)。

用 OOP 的思維畫出來大概是:

graph LR CPU -->|讀寫暫存器| PPU CPU -->|讀寫暫存器| APU CPU -->|讀寫暫存器| JOY[Joypad] CPU -->|讀寫 ROM/RAM| MAP[Mapper] PPU -->|讀取 CHR| MAP APU -->|DMC 取樣| CPU

在 C/C++ 的模擬器裡,這種關係用指標隨便串就好。但在 Rust 裡?CPU 不能同時持有 PPU、APU、Mapper 的 &mut 引用——因為它們都被 System struct 擁有,而 Rust 不允許對同一個 struct 的多個欄位同時取得可變引用(透過方法呼叫時)。

Rc<RefCell<>>:單執行緒的共享擁有權

解法是 Rc<RefCell<>>——Rust 標準庫提供的「引用計數 + 執行期借用檢查」組合:

pub struct System {
    pub cpu: Cpu,                           // CPU 獨佔擁有
    pub ppu: Rc<RefCell<Ppu>>,              // 共享擁有
    pub apu: Rc<RefCell<Apu>>,              // 共享擁有
    pub joypad1: Rc<RefCell<Joypad>>,       // 共享擁有
    cpu_cycles: Rc<Cell<u64>>,              // 共享計數器
    ppu_cycles: Rc<Cell<u64>>,              // 共享計數器
}

為什麼 CPU 是直接擁有,其他都是 Rc<RefCell<>>?因為 CPU 的 Bus(記憶體匯流排)裡面也需要持有 PPU、APU 的引用——當 CPU 執行 STA $2000 這種指令時,Bus 要把資料轉發給 PPU 暫存器。所以 PPU 同時被 System Bus 持有——這就是共享擁有權:

System ──owns──→ Rc<RefCell<Ppu>> ←──clone────┐
                                              │
CPU ──owns──→ Bus ──owns──→ PpuBusIo ────owns─┘

Rc::clone() 不複製資料,只增加引用計數。RefCell 在你 borrow_mut() 時做執行期檢查——如果已經有人借了,直接 panic。

Drop-and-Reborrow:和借用檢查器跳舞

最精彩的 pattern 出現在 APU 的 DMC(Delta Modulation Channel)。DMC 播放聲音時需要從 CPU 的記憶體匯流排讀取 sample data——但此時 APU 已經被借用了:

fn step(&mut self) {
    // ... 執行 CPU 指令 ...

    // 借用 APU 來 tick
    let mut apu = self.apu.borrow_mut();
    for _ in 0..total_cpu_cycles {
        apu.tick();
    }

    // DMC 需要從 CPU bus 讀資料
    if let Some(addr) = apu.dmc_sample_request() {
        drop(apu);  // ← 先還回去!
        let val = self.cpu.bus.read(addr);  // 現在可以用 Bus 了
        let mut apu = self.apu.borrow_mut();  // 重新借
        apu.dmc_load_sample(val);
    }
}

看到那個 drop(apu) 了嗎?如果不手動 drop,apu 的可變借用還活著,而 self.cpu.bus.read(addr) 裡面 Bus 的 ApuCompositeBusIo 也會嘗試 borrow_mut() APU——兩個可變借用同時存在,RefCell 直接 panic。

這種「先還再借」的模式在 C++ 裡完全不需要——你隨時都能透過指標存取任何東西。但 Rust 強迫你明確管理每一段借用的生命週期,結果是每個存取路徑都清清楚楚,不會有「誰在什麼時候改了什麼」的幽靈 bug。

Cell vs RefCell:Copy 型別的捷徑

注意 cycle counter 用的是 Rc<Cell<u64>> 而不是 Rc<RefCell<u64>>

// Cell — 直接 get/set,沒有借用追蹤
self.cpu_cycles.set(self.cpu.cycles);
let ppu_cycles = self.ppu_cycles.get();

// RefCell — 需要 borrow/borrow_mut,有執行期開銷
let mut ppu = self.ppu.borrow_mut();
ppu.tick();

Cell<T> 只能用在實作 Copy 的型別上(數字、bool 等),但好處是完全沒有借用追蹤的開銷——直接覆寫值。在每 CPU cycle 都要更新的計數器上,這個小優化累積起來是有意義的。

Bevy 的型別系統魔法

NonSendMut:「我知道我不安全」

NES 模擬器核心用了 Rc<RefCell<>>,而 Rc 不是 Send(不能安全地跨執行緒傳遞)。Bevy 預設會在多執行緒上排程系統——所以直接把 NesSystem 塞進 Res<> 會編譯失敗:

// 🔴 編譯錯誤:NesSystem 不是 Send
fn my_system(nes: ResMut<NesSystem>) { ... }

解法是 NonSendMut——Bevy 提供的特殊存取器,告訴排程器「這個系統只能在主執行緒上跑」:

// ✅ 編譯通過:Bevy 保證只在主執行緒執行
fn run_emulation_frame(
    mut nes: NonSendMut<NesSystem>,
    nes_input: Res<NesInput>,
    audio_buf: Res<AudioBuffer>,
    // ...
) {
    nes.sys.joypad1.borrow_mut().set_buttons(nes_input.0);
    nes.sys.run_until_frame();
}

這是 Rust 型別系統最美妙的地方之一:限制不是用文件或約定來表達,而是用型別。你不需要在 README 寫「注意:這個函式只能在主執行緒呼叫」——如果有人把它改成 Res<NesSystem>,編譯器就會阻止。

跨執行緒的音訊:Arc<Mutex<>>

音訊 buffer 需要在模擬執行緒(producer)和 cpal 音訊執行緒(consumer)之間共享。這裡不能用 Rc<RefCell<>>——必須用跨執行緒安全的 Arc<Mutex<>>

#[derive(Resource, Clone)]
pub struct AudioBuffer {
    pub buffer: Arc<Mutex<VecDeque<f32>>>,
}

Rust 不會讓你把 Rc<RefCell<>> 傳給另一個執行緒——如果你試著這樣做,編譯器會直接告訴你「Rc 不是 Send」。所以你被迫根據使用情境選擇正確的共享方式:

場景 型別 原因
NES 核心(單執行緒) Rc<RefCell<>> 不需要原子操作的開銷
音訊 buffer(跨執行緒) Arc<Mutex<>> 需要 Send + 互斥鎖
cycle 計數器(單執行緒、Copy) Rc<Cell<>> 最低開銷

在 C++ 裡,你可能會「為了安全」統一用 std::shared_ptr + std::mutex,但代價是不需要鎖的地方也付了鎖的成本。Rust 的型別系統讓你精確地只在需要的地方付出代價。

Exclusive System:獨佔整個世界

初始化模擬器時,我們需要把 NesSystem(非 Send)插入 Bevy 的 World——這需要直接存取 World:

pub fn setup_emulation(world: &mut World) {
    let rom_path = world.resource::<RomPath>();
    let rom = std::fs::read(&rom_path.0).expect("Failed to read ROM");
    let sys = System::new(rom, sample_rate);

    world.insert_non_send_resource(NesSystem {
        sys,
        paused: false,
        accumulator: 0.0,
    });
}

&mut World 是一個 exclusive system——它獨佔整個 World 的存取權限,不能和其他系統平行跑。Bevy 用這個來處理「需要完全控制權」的初始化邏輯。

在函式簽名裡你就能看出意圖:

  • world: &mut World → 「我要獨佔一切,請讓所有人都先停下來」
  • nes: NonSendMut<NesSystem> → 「我只要這一個資源,但我只能在主執行緒」
  • input: Res<NesInput> → 「我只讀這個資源,隨便誰跟我同時跑都行」

型別本身就是系統的平行化說明書。

Plugin:模組化的秘密武器

Bevy 的 Plugin trait 讓你把相關的系統、資源、事件打包成一個模組:

pub struct CrtPlugin;

impl Plugin for CrtPlugin {
    fn build(&self, app: &mut App) {
        load_internal_asset!(app, CRT_SHADER_HANDLE,
            "../assets/shaders/crt.wgsl", Shader::from_wgsl);
        app.add_plugins(Material2dPlugin::<CrtMaterial>::default());
    }
}

NES 模擬器的 main.rs 只有 58 行,因為每個功能都是一個獨立的 Plugin 或系統,各自負責自己的資源和生命週期:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin { ... }))
        .add_plugins(CrtPlugin)
        .add_plugins(DebugUiPlugin)
        .insert_resource(AudioBuffer { ... })
        .insert_resource(NesInput(0))
        .add_systems(Startup, (setup_emulation, setup_video, setup_audio).chain())
        .add_systems(Update, (read_input, run_emulation_frame).chain())
        .run();
}

.chain() 保證系統依序執行(先讀輸入,再跑模擬),沒有 chain 的系統 Bevy 會自動判斷是否可以平行。

這種聲明式的架構跟 React 的理念很像——你描述「什麼東西存在」和「它們之間的關係」,框架負責排程和執行。差別在於 Bevy 的描述是用 Rust 的型別系統寫的,所以所有約束都在編譯期就被驗證了。

AsBindGroup:用 Derive Macro 寫 Shader

CRT 效果的 shader 材質用 Bevy 的 AsBindGroup derive macro 自動生成 GPU bind group:

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct CrtMaterial {
    #[uniform(0)]
    pub params: LinearRgba,
    #[texture(1)]
    #[sampler(2)]
    pub source_texture: Option<Handle<Image>>,
}

impl Material2d for CrtMaterial {
    fn fragment_shader() -> ShaderRef {
        CRT_SHADER_HANDLE.into()
    }
}

#[uniform(0)]#[texture(1)]#[sampler(2)] 這些 attribute 對應到 WGSL shader 裡的 binding:

@group(2) @binding(0) var<uniform> params: vec4<f32>;
@group(2) @binding(1) var source_texture: texture_2d<f32>;
@group(2) @binding(2) var source_sampler: sampler;

Rust 端改了 struct 欄位,WGSL 端沒改?你會在執行期看到明確的錯誤訊息。雖然不是完美的編譯期驗證,但比起手寫 bind group layout 再偷偷打錯一個 binding index,好太多了。

BusIo Trait:Rust 的 Trait 如何取代 Virtual Function Table

NES 的 CPU 透過記憶體映射 I/O 跟各個硬體元件溝通——寫入 $2000 是 PPU 控制暫存器,寫入 $4015 是 APU 狀態暫存器。在 C++ 裡你可能用 virtual function:

class BusDevice {
public:
    virtual uint8_t read(uint16_t addr) = 0;
    virtual void write(uint16_t addr, uint8_t val) = 0;
};

在 Rust 裡用 trait:

pub trait BusIo {
    fn read(&mut self, addr: u16) -> u8;
    fn write(&mut self, addr: u16, val: u8);
}

然後用組合模式把各個設備串起來:

pub struct ApuCompositeBusIo {
    pub inner: CompositeBusIo,       // PPU + Joypad
    pub apu: Rc<RefCell<Apu>>,
}

impl BusIo for ApuCompositeBusIo {
    fn read(&mut self, addr: u16) -> u8 {
        match addr {
            0x4015 => self.apu.borrow_mut().read_register(addr),
            _ => self.inner.read(addr),
        }
    }
}

跟 C++ 的 virtual dispatch 不同,Rust 的 trait 實作在編譯期就被確定了(除非你用 dyn Trait)。在模擬器這種每 cycle 都要呼叫的 hot path 上,靜態分派比動態分派快不少——因為編譯器可以 inline 整個呼叫鏈。

回顧:Rust 的限制帶來了什麼?

Rust 的限制 被逼出來的設計 意外的好處
沒有 GC,所有權唯一 ECS 架構(元件分離儲存) 自動平行化排程
同時只有一個 &mut Rc<RefCell<>> 共享擁有權 每個借用明確可追蹤
Send/Sync trait bound NonSendMut 標記主執行緒資源 執行緒安全在編譯期驗證
沒有繼承 Trait + 組合模式(BusIo) 靜態分派、可 inline
嚴格的型別系統 系統參數即存取宣告 排程器自動推導依賴

Bevy 不是「剛好用 Rust 寫的遊戲引擎」——它是一個被 Rust 的型別系統塑造出來的遊戲引擎。每個 API 設計都能追溯到某個 Rust 的語言特性或限制。

寫 NES 模擬器前端的過程讓我深刻體會到這一點:當你和借用檢查器搏鬥的時候,其實是在和它合作設計一個更清晰的架構。那些讓你抓狂的編譯錯誤,往往指向的是你的設計裡真正模糊的地方——誰擁有什麼、誰能改什麼、什麼時候能改。

把這些問題在編譯期就解決掉,比在凌晨三點 debug 一個跨執行緒的 race condition 好太多了。

參考資源