天天看點

3 年寫了 10 萬行代碼開發者吐槽:當初用 Rust 是被忽悠了

作者:InfoQ

作者 | LogLog Games

譯者 | 核子可樂

策劃 | 李冬梅

編者按:本文作者是國外一位用 Rust 程式設計語言開發遊戲的開發者,這位作者和他的朋友兩人成立了一家小型獨立遊戲開發工作室,在過去幾年中他們緻力于開發跨不同引擎的各種遊戲。

他們熱愛遊戲,并在程式設計和建立各種應用程式(網絡或桌面應用程式)方面擁有豐富的經驗。他們用 Rust 建構了自己的引擎,稱為 Comfy Engine,用于他們的遊戲。本文就講述了他們這三年來使用 Rust 程式設計語言開發遊戲的心路曆程。下列内容為 InfoQ 翻譯并整理。

免責聲明:這篇文章是我個人對于多年來感受與困境的總結,也驗證了我聽到過的某些被反複強調的所謂“真理”。我使用 Rust 進行了數千個小時的遊戲開發,期間也完成過多款遊戲作品。本文不是想向大家炫耀我有多牛、有多成功,最主要的目的在于破除“你覺得 Rust 不好用,是因為你的經驗還不夠”這一廣泛存在的荒謬論點。

本文也絕不是科學評估或者嚴謹的 A/B 研究。我們是一支由兩人組成的小型獨立遊戲開發團隊,單純是想用 Rust 做遊戲并賺取持續發展的必要收益。是以如果大家能從投資者那邊拿到幾無上限的資助、一個項目就要做上好幾年,而且并不介意不斷開發各種必要系統,那我們的經驗對您并不适用。我們的訴求很簡單:最多在 3 到 12 個月的周期之内完成遊戲的開發和釋出,并借此賺取回報。這就是本文的立論基礎,跟快樂學習、探索 Rust 沒有半毛錢關系。并不是說後一種目标不好,隻是我們這裡讨論的是能不能用 Rust 養家糊口、能不能在商業層面找到可以自給自足可行路徑的問題。

我們已經在 Rust、Godot、Unity 和虛幻引擎上開發過一些遊戲,各位沒準在 Steam 上遊玩過了。我們還從頭開始使用簡單的渲染器制作出了自己的 2D 遊戲引擎。多年以來,我們也在多個項目中使用了 Bevy 和 Macroquad,包括一些相當重要的項目。我本人還有一份全職工作,負責的是後端 Rust 開發。另外需要強調,本文内容并非源自經驗淺薄的新手——我們在這三年多時間裡已經編寫過超 10 萬行 Rust 代碼。

我寫下這篇文章的目的,就是破除一個曾被反複提及、誤導了無數新手的荒謬觀點。希望在讀了這篇文章之後,大家能了解我們為什麼要放棄 Rust 作為遊戲開發工具。我們不會停止遊戲開發,隻是不再繼續使用 Rust。

如果各位的目标是學習 Rust,能感受到它的優秀之處而且樂于接受技術挑戰,那完全沒有問題。作為有經驗的開發者,我會在文章中把應用場景明确區分開來,而不像很多所謂 Rust 老鳥那樣不問你是單純需要技術示範、還是想認真推出一款遊戲,就盲目鼓吹 Rust 語言。我發現整個 Rust 社群的注意力都主要集中在技術身上,反而對遊戲開發中“遊戲”的部分熟視無睹。舉個例子,我曾參加過一場 Rust 遊戲開發的線下聚會,結果在提案中居然看到了“有人想在會上展示一款遊戲,當否請批示”的紙條……着實給我整無語了。

“你覺得 Rust 不好用,是因為你的經驗還不夠”

學習 Rust 确實是種有趣的經曆,因為人們常常發現“這肯定是個隻有我遇到過的特殊問題”其實是正在困擾更多開發者的普遍模式,于是每位學習者都必須調整思路并消化這些“怪癖”才能提高生産力。而且這些問題往往出現在相當基礎的層面,比如 &str 和 String 或者.iter() 與.into_iter()的差別等等。總而言之,我們潛意識裡認為應該沒差別的事物,在 Rust 這邊往往邊界森嚴。

我承認,其中一些屬于必要之痛,在積累到足夠的經驗之後,使用者就可以不假思索地預見到潛在問題并提高工作效率。我非常享受用 Rust 編寫各種實用程式和 CLI 工具的體驗,而且在多數情況下效率也比用 Python 更高。

可話雖如此,Rust 社群中的确存在一股壓倒性的力量。每當有人提到自己在使用 Rust 語言時遇到的基礎問題,他們就粗暴回應說“你覺得 Rust 不好用,是因為你的經驗還不夠”。當然,這不僅僅是 Rust 的問題。我們使用 ECS 時有這種現象,在使用 Bevy 時也有這種現象。甚至是在我們使用自己標明的任何架構(無論是響應式方案還是即時模式)制作 GUI 時,也都有類似的困擾。“你覺得 xx 不好用,是因為你的經驗還不夠”。

多年以來我一直對此深信不疑,也一直在努力學習和嘗試。我在多種語言中都遇到過類似的情況,并且發現自己在掌握訣竅之後效率确有提升,慢慢學會了預測語言和類型系統的“脾性”并能有效回避問題。

但我想強調一點,在花掉了大約三年的時間,在 Rust 的整個架構/引擎生态系統中編寫了超過 10 萬行遊戲相關代碼之後,我發現很多(甚至是大多數)問題仍然存在。是以如果各位不想沒完沒了地重構代碼并将程式設計視為一種不斷挑戰自我的趣事,而單純想要安安靜靜地用它完成任務,那千萬别用 Rust。

最基本的問題就是借用檢查器經常選個最讓人難受的時機強制進行重構。Rust 的粉絲們覺得這事沒問題,因為能讓他們“編寫出更好的代碼”,但我花在這門語言上的時間越多,就越是懷疑這話到底靠不靠譜。好的代碼确實是靠不斷疊代思路并做出嘗試來實作的,雖然借用檢查器可以強制進行更多疊代,但并不代表這就是編寫代碼的理想方式。我經常發現自己被牢牢卡死,根本沒辦法稍後再做修複——這導緻我根本沒辦法用輕松愉快的心情把腦袋裡的靈感絲滑順暢地表達成代碼。

在其他語言中,人們可以在寫完代碼之後就把它抛在腦後,我覺得這才是實作良好代碼的最佳途徑。舉個例子,我正在編寫一個角色控制器,唯一的目标就是用它操縱角色移動和執行操作。完成之後,我就可以開始建構關卡和敵人了。我不需要這個控制器有多好,能起效就足夠了。如果有了更好的點子,我當然可以稍後把它删掉再換上個更好的。但在 Rust 中,萬事萬物之間都有聯系,導緻我們經常遇到沒辦法一次隻做一件事的情況。于是我們的每一個開發目标都變得極其複雜,并且最終會被編譯器重構,哪怕那些一次性代碼也是如此。

Rust 擅長大規模重構,但這是為了解決借用檢查器自身造成的問題

人們常說 Rust 最大的優勢之一就是易于重構。這話沒錯,而且我也有切身體會,比如可以無所畏懼地重構代碼庫中的重要部分,不必擔心運作起來出什麼問題。但事情真這麼簡單美好嗎?

事實上,Rust 也是一種比其他語言更頻繁迫使使用者進行重構的語言。每當我開發一段時間,就會突然被借用檢查器當頭棒喝,并意識到“好吧,我添加的這項新功能無法編譯,而且除了代碼重構之後沒有其他解決辦法”。

有經驗的人們常常會說,“你覺得 Rust 不好用,是因為你的經驗還不夠”。雖然這話原則上沒錯,但遊戲是種複雜的狀态機,其需求一直在變化。用 Rust 編寫 CLI 或者伺服器 API,跟用它編寫獨立遊戲是完全不同的兩種體驗。畢竟我們開發遊戲的目标是為玩家提供良好體驗,而不是一組僵化死闆的通用系統,是以必須考慮人們在遊玩過程中随時發生的需求變化,特别是那些需要從根本上做出調整的變更。Rust 的高度靜态特性與過度檢查的傾向,明顯有違遊戲軟體的天然需求。

很多人可能會反駁說,借用檢查器和代碼重構并不是壞事,它們能有效提高代碼的品質。确實,對于那些強調代碼的項目來說,Rust 的特性有其積極的一面。但至少在遊戲開發這邊,多數情況下我需要的不是“高品質的代碼”,而是“能早點試玩的遊戲”,這樣我才能快速測試自己的玩法設計思路。Rust 的很多堅持,其實就是逼着我在“要不要打破流程并花上整整 2 個小時進行重構”和“在客觀上讓代碼品質變得更糟”之間二選其一。我真的快要崩潰了……

這裡我還要放句大不敬的話厥詞:至少對于獨立遊戲來說,可維護性根本就是個僞訴求,我們更應該追求疊代速度。其他語言可以更簡單地解決眼下的問題,又不必過度犧牲代碼品質。而在 Rust 中,我們永遠需要選擇要不要向函數中添加第 11 個函數,要不要添加另一個 Lazy<AtomicRefCell<T>>,要不要将其放入另一個對象,要不要添加間接(函數指針)并惡化疊代體驗,或者幹脆花點時間重新設計這部分代碼。

間接隻能解決部分問題,而且總會以損害開發體驗為代價

Rust 所主張、而且特别有效的一種基本解決思路,就是添加一個間接層。我們以 Bevy 事件為典型案例,對于“需要配合 17 個參數來完成任務”這類問題,Bevy 事件都是首選的解決辦法。我也很努力地想從好的方面了解這種情況,但 Bevy 确實高度倚重事件、而且總想把所有内容都塞進單一系統。

借用檢查器的很多問題,都可以通過間接執行某些操作來解決。或者也可以複制/移出某些内容,執行該操作,然後再把其轉移回來。又或者将其存儲在指令緩沖區内并稍後執行。這通常會在設計模式上引發一些神奇的現象,比如我就發現可以通過提前保留 entity id 并結合指令緩沖區來解決很大一部分問題(例如 hecs 中的 World::reserve,請注意是 &world 而不是 &mut world)。這些模式有時候确實效果不錯,而且足以解決種種極其困難的問題。另一個例子則是在 Thunderdome 中提到的 get2_mut,乍看之下沒什麼道理,但經驗多了之後人們發現它能解決很多意想不到的問題。

關于 Rust 那陡峭的學習曲線,我其實不想争論太多,畢竟每種語言都各有特性。但我想提醒各位的是,哪怕是積累到相當豐富的經驗之後,Rust 中也仍然存在很多基本問題。

回到正題,雖然前面提到的一些方法可以解決特定問題,但也有不少情況根本無法通過專門的、精心策劃的庫函數來解決。也正因為如此,很多人才建議直接使用指令緩沖區或者事件隊列“将問題延後”,進而切實有效地解決問題。

遊戲開發的具體問題在于,我們經常需要關注互相關聯的多個事件與特定的時間安排,而且得同時管理大量狀态。跨事件屏障進行資料移動,意味着事物的代碼邏輯會被割裂成兩個部分——就是說哪怕業務邏輯本身仍是一個整體,在認知意義上也應被視為彼此獨立。

經常混迹 Rust 社群的朋友肯定知道,那邊的老人兒們會說這是件好事,能保證關注點分離并讓代碼“更幹淨”等等。在他們看來,Rust 的設計者是最聰明的,是以如果有什麼正常功能難以實作——那不是設計有誤,而是他們想強迫你采取正确的實作方法……

于是乎,在 C#中 3 行代碼能搞定的功能在 Rust 這邊需要 30 行代碼,還得一分為二。我給大家舉個典型例子:“在疊代目前查詢時,我想檢查另一功能上的元件并涉及一堆相關系統”(比如生成粒子、播放音頻等)。不用問就知道,Rust 社群上的那幫鐵粉會說,“這明顯是個 Event,是以你不應該内聯編寫代碼”。

可想象一下,在這樣的規則下功能實作起來将有多麻煩(以下為 Unity 版代碼):

if (Physics.Raycast(..., out RayHit hit, ...)) {
  if (hit.TryGetComponent(out Mob mob)) {
    Instantiate(HitPrefab, (mob.transform.position + hit.point) / 2).GetComponent<AudioSource>().clip = mob.HitSounds.Choose();
  }
}           

這隻是個相對簡單的例子,但這樣的需求随時可能出現。特别是在實作新機制或者測試某項新功能時,我們最需要的是可以直接編寫,而暫時不想考慮什麼可維護性。我們要做的是很簡單的東西,隻想它在正确的位置上運作。我不需要 MobHitEvent,因為我還打算同時檢查 raycast 等其他相關功能。

我也不想檢查“Mob 上存不存在 Transform”,因為我是在開發遊戲,是以每個 entity 當然都有 transform。但 Rust 不允許我使用.transform,而且一旦我不小心讓查詢發生了原型重合,由此引發的雙重借用就會立刻導緻崩潰。

我也不想檢查音頻源是否存在。我當然可以用.unwrap().unwrap(),但細心的 Rust 會注意到這裡沒有傳遞 world。在 Rust 看來,運作場景是全局 world 嗎?不是應該用依賴注入将查詢寫成系統中的中車個參數,并将所有内容都預先安排就緒嗎?.Choose 是不是假設存在一個全局随機數生成器?線程呢?

我知道,很多粉絲都會說什麼“但這不利于未來擴充”、“後續可能引發崩潰”、“你不能假設全局 world,因為 blabla”、“你沒考慮過多人遊戲的問題嗎”或者“這種代碼品質敢用嗎”之類……我都知道。但就在各位忙于挑錯的同時,我已經完成了功能實作并繼續前進。很多代碼其實就是一次性的産物,我在編碼過程中實際是在考慮目前實作的遊戲功能會如何影響玩家體驗。我并不在乎“這裡應該使用哪種正确的随機生成器”、“能不能假設單線程場景”或者“嵌套查詢當中的原型重合該怎麼處理”之類的技術問題,而且後續也沒有出現編譯器錯誤或者運作時借用檢查器崩潰。我隻想在傻瓜引擎裡用點傻瓜語言,保證自己能在編寫代碼的時候隻考慮遊戲邏輯,行嗎?

用 ECS 解決錯誤類型問題

由于 Rust 類型系統和借用檢查器的天然特性,ECS 自然而然成了幫且我們解決“如何讓某個東西引用其他東西”的方案。遺憾的是,我認為其中存在大量術語混淆,不單是不同的人對其有不同的定義,而且社群中有不少人會把本不屬于 ECS 的東西硬安在它頭上。下面咱們就做一點明确的區分和闡述。

首先,讓我們先聊聊因為各種原因而導緻開發者無法實作的東西(為了控制篇幅,這裡不做過多的細節區分和讨論):

  • 具有實際指針的 pointer-y 資料。這裡的問題很簡單,如果字元 A 跟随 B,且 B 被删除(并取消配置設定),則該指針将無效。
  • Rc<RefCell<T>>與弱指針相結合。雖然可以實作,但在遊戲中性能往往非常重要,而且受記憶體局部性的影響,這樣的資源開銷确實會帶來可感覺的影響。
  • Entity 數組的索引。在第一種情況下出現了一個無效指針,這時候如果我們擁有一個索引并删除了一個元素,則該索引可能仍然有效但指向其他内容。

這時出現了一個神奇的解決方案,能幫助我們擺脫所有問題——這就是我個人強烈推薦的 generational arenas——它又小又輕巧,而且能在保持代碼庫可讀性的同時實作既定功能。這種穩定實作既定功能的能力,在 Rust 生态系統中其實相當罕見。

Generational arena 在本質上就是一個數組,隻不過我們的 id 不再是一個索引,而是一個(index, generation)元組。該數組本身存儲的是(generation, value)元組。為了簡單起見,我們可以想象每次在索引處删除某些内容時,隻需增加該索引處的生成計數器即可。之後隻需要確定對 arena 進行索引時,始終檢查提供索引的 generation 是否與數組中的 generation 相比對。如果該條目被删除,則 slot 将擁有更高的 generation,而索引也将“無效”、就如同該條目不存在一樣。這種方法還能解決其他一些非常簡單的問題,比如保留一個空閑的 slot 清單,以便在必要時向這裡插入以加快操作速度——當然,這些都跟使用者無關。

關鍵在于,這種方式終于讓 Rust 這類語言能夠完全避開借用檢查器,允許我們“使用 arenas 進行手動記憶體管理”,進而在保證 100%安全的前提下無需接觸任何指針。如果非要說 Rust 有什麼讓人喜歡的優點,那就是它了。特别是對于像 thunderdome 這樣的庫,二者确實結合得很好,而且這種資料結構也非常符合語言的設計思路。

有趣的來了。大多數人所認為的 ECS 優勢,其實在很大程度上是 generational arenas 的優勢。當人們說“ECS 提供了很好的記憶體局部性”時,他們對 mobs 使用的 Query<Mob, Transform, Health, Weapon>查詢,其本質其實相當于 Arena<Mob>。具體 struct 定義為:

struct Mob {
  typ: MobType,
  transform: Transform,
  health: Health,
  weapon: Weapon
}           

當然,這種定義方式并不能展現 ECS 的全部優勢。但我想強調的是,我們想在使用 Rust 的同時盡量回避 Rc< RefCell<T>>,并不一定非得靠 ECS——相反,真正能幫上盡快的可能是 generational arena。

回到 ECS,我們可以從多種不同的角度了解 ECS 的作用:

ECS 作為動态組合,允許将多個元件組合起來以共同存儲、查詢和修改,而不必綁定在單一類型中。這裡最典型的例子,就是很多人實際上會在 Rust 當中用“state”狀态元件來标記 entities(因為也沒有其他更好的方法可以實作)。比方說,我們想要查詢所有遊戲中的 Mobs(小怪),但其中一些可能已經變成了不同的類型。我們可以簡單執行 world.insert(entity, MorphedMob),之後再查詢(Mob, MorphedMob)、(Mob, Not<MorphedMob>) 或者 (Mob, Option<MorphedMob>),要麼就是檢查代碼中是否存在所述元件。根據不同的 ECS 實作,這些方法的具體操作可能有所差別,但其本質就是在“标記”或者“拆分”entities。

不止如此,除了 Mob 之外,類似的情況也完全可能出現在 Transform、Health、Weapon 或者其他元素之上。比如說原本未裝武器的小怪沒有 Weapon 元件,但在它拾起武器後我們就需要将其插入 entity。如此一來,我們就能在單獨的系統中拆借所有帶有武器的小怪。

我還會在動态合成中引入 Unity 的“EC”方法,雖然它并不屬于純粹的“帶系統的 ECS”,但在很大程度上确實會使用元件進行合成。而且除了性能問題之外,其最終效果确實非常類似于“純 ECS”。這裡我還想再誇誇 Godot 的節點系統,其中各子節點通常被用作“元件”。雖然這與 ECS 無關,但卻與“動态組合”有關,因為它允許在運作時上插入/移除節點,進而改變 entities 的行為。

另外應當指出的是,“将元件拆分成盡可能小的形式以最大程度實作複用”也已經成為一種最佳實踐。我參與過很多次争論,也有人試圖說服我,提醒我最好把 Position 跟 Health 從對象當中剝離出來。而如果不這樣做,我的代碼最終就會像意大利面那樣彼此糾纏、一塌糊塗。

在多次嘗試了這些方法之後,我現在已經堅定相信:除非關注極緻性能,否則對于絕大多數開發場景,都壓根沒必要拆分到這個程度。我本人在拆分之外還嘗試過所謂“胖元件”方法,而且感覺“胖元件”其實更适合那些需要在大量正在發生的事情上建立特有邏輯的遊戲。比如說,将 Health 值模組化為泛型機制在簡單模拟中倒是可以,但在不同的遊戲裡,玩家生命值跟敵方生命值其實對應着不同的邏輯。我還經常會想要讓不同類型的非玩家 entities 使用不同的邏輯,比如怪物和牆壁各有自己的生命值。實踐經驗告訴 ,粗暴将其概括為“生命值”反而令代碼變得模糊,導緻生命值系統中充斥着 if player { ... } else if wall { ... }這樣的語句。這樣并不好,還不如單獨保留所謂“胖”玩家或者牆壁系統。

作為數組的動态結構,受到元件在 ECS 中存儲方式的影響,我們可以對 Health 元件進行疊代并使其在記憶體中排列在彼此相鄰的位置上。有些朋友可能不太了解,這意味着我們不再需要使用 Arena<Mob>,而可以使用:

struct Mobs {
  typs: Arena<MobType>,
  transforms: Arena<Transform>,
  healths: Arena<Health>,
  weapons: Arena<Weapon>,
}           

而且同一索引上的值屬于同一“entity”。手動執行這類操作非常煩人,而且受我們以往開發經曆和使用語言的影響,大家可能總有些時候被迫選擇手動操作。但多虧了現代 ECS,我們隻需要在元組中寫出自己的類型即可輕松實作此功能,再由底層存儲機制将正确的内容組合在一起。

我還把這類用例稱為 ECS as performance,因為這樣做的目的不是“因為我們需要組合”,而是“我們想要更多的記憶體局部性”。我承認其對應一部分有效應用,但至少對于絕大多數已經發行的獨立遊戲來說,确實沒有任何必要。我之是以強調“已發行”的遊戲,是因為反對者當然可以輕松設計出高度複雜、必須借助這種機制的原型,但這既跟玩家體驗沒啥關系、也不值得本文多費唇舌。

作為 Rust 借用檢查器的解決方案,我認為這就是大多數在用 ECS 實作的效果——或者更确切地說,這就是他們選擇 ECS 的理由。非要說有什麼差別,那就是 ECS 确實非常流行,也是 Rust 的推薦選項,确實能夠解決很多問題。反正如果我們隻是在傳遞 struct Entity(u32, u32)的話,由于它既簡單又可以直接 Copy,那實在沒必要像 Rust 要求的那樣糾結什麼生命周期。

我之是以把這部分單獨開了一節,是因為很多人在使用 ECS 來解決“我該把對象放在哪裡”的問題,而不是真的在用它進行組合或者提升性能。這本身并無不妥,隻是當人們最終在網上展開争論時,總會有人想強調其他人的辦法是錯的、認為對方應該以特定方式使用 ECS。别鬧了,我發現很多人甚至連别人用 ECS 的原因都沒搞懂。

ECS 作為動态建立的 generational arenas,單純就是為了實作最基本的功能保障而生。換句話說,為了同時實作 storage.get_mut::<Player>() 和 storage.get_mut::<Mob>之類的操作,我要麼被迫重新發明一堆古怪的内部可變性,要麼就隻能選擇它。Rust 有這麼個特點 :當你按照它的脾氣做事時,它就既有趣又漂亮;可一旦你想做些它不喜歡的東西時,情況很快就會變成“我得重新實作自己的 RefCell 來實作這項特定功能”。

我想說的是,雖然 generational arenas 不錯,但最大的缺點之一就是必須為我們需要使用的每個 arena 定義一個變量和類型。如果在每個查詢中隻使用一個元件,當然可以通過 ECS 來解決;但如果不需要完整的原型 ECS,而能按需使用每種類型所對應的 arema,那不是更好?現在當然有很多方法可以做到這一點,但我已經不想繼續耗費心神來部分重新發明 Rust 生态系統了。告别 Rust 之後,我現在滿心輕松。

ECS 能火是因為 Bevy,我覺得這肯定隻是個笑話。但必須承認,憑借其極高的受歡迎程度和包羅萬象的方法,Bevy 值得作為 ECS 中的一個單獨角度。因為對于大多數引擎/架構來說,ECS 是個選項,是人們選擇是否要使用的庫。但 Bevy 之于遊戲則不可或缺,在很多情況下整個遊戲就是 ECS。

另外我要專門強調的是,雖然我個人有種種不滿,但 Bevy 對于 ECS API 和 ECS 本身的易用性确實做出了巨大的改進。任何見過、或者說用過 specs 之類的人們,都知道 Bevy 在改善 ECS 易用性方面做得有多好、幾近幾年來的進步有多大。

話雖如此,但我認為這恰恰也是我對 Rust 生态系統在看待 ECS、特别是 Bevy 的方式感到不滿的核心原因。ECS 是一種工具,一款非常具體的工具,可以解決非常具體的問題,而且是有成本的。

這裡我們稍微岔開話題,再聊聊 Unity。無論其授權、高管層或者商業模式發生了怎樣的變動,我們都必須承認 Unity 就是推動獨立遊戲取得成功的主要驅力之一。從 SteamDB 圖表來看,Steam 平台上目前有近 4.4 萬款 Unity 遊戲;排名第二的是虛拟引擎,有 1.2 萬個;其他引擎則遠遠落後。

近年來一直在關注 Unity 的朋友肯定聽說過 Unity DOTS,這本質上就是 Unity 版的“ECS”(以及其他面向資料的東西)。現在,作為 Unity 曾經、當下以及未來的使用者,我對此感到非常興奮。而之是以如此興奮,就是因為它能與現有遊戲對象方法共存。雖然必然涉及很多複雜元素,但從本質上講,使用者們都期待着這樣的更新。我們既可以在一款遊戲中使用 DOTS 來完成某些效果,也可以像以前那樣繼續使用标準遊戲對象場景樹,并把這兩種方法順暢結合在一起。

我不相信 Unity 領域會有人在了解了 DOTS 的意義之後,認為這是個不該存在的糟糕功能。當然,我也不覺得有人會認為 DOTS 就是 Unity 的全部未來,可以直接把遊戲對象删除掉,強制要求所有 Unity 作品都轉向 DOTS。哪怕不談可維護性和向下相容性,這也是非常愚蠢的作法,因為仍然有很多工作流程是天然适合遊戲對象機制的。

相信很多用過 Godot 的朋友也會有類似的觀點,特别是用過 gdnative 的朋友(例如通過 godot-rust)。雖然節點樹可能并非适合一切需求的最佳資料結構,但它們在很多場景下确實非常友善。

說回到 Bevy,我覺得很多人都沒意識到“ECS 管一切”的方法涵蓋範圍是有多廣。舉個明顯的例子,在我看來,其中的一個大昏招就是 Bevy 的 UI 系統——這東西作為痛點存在已經不是一天兩天了,特别是加上“我們今年内肯定會開始開發編輯器”之類的承諾。隻要稍微看看 Bevy 的 UI 示例,就會發現裡頭根本就沒多少東西;再随便追溯一下源代碼,比如一個在懸停和單擊時會改變顔色的按鈕,原因就不言自明了。實際上,在嘗試用 Bevy UI 完成某些重要的開發任務之後,我可以公開這樣講,那難受程度甚至遠超大家的想象。因為 ECS 在執行任何跟 UI 相關的操作時,都是無比麻煩且無比痛苦。是以,Bevy 上跟編輯器最接近的方案就是使用 egui 這種第三方 crate。而且不止是 UI,我想說的是堅持把包括 UI 在内這麼多東西交給 ECS 處理,實在有點反人類。

Rust 中的 ECS 相當于把其他語言中的普通工具,轉化成了一種近乎宗教信仰的必要之物。我們使用某種工具本來應該是因為它更簡單、更好用,可現在變成了不用不行。

程式設計語言社群往往各有傾向。多年以來我曾經先後使用過多種語言,而且發現這些傾向都稻有趣。我能想到的類似于 ECS 之于 Rust 的,也就是 Haskell 了。雖然有點簡單粗暴,但我個人的感覺是 Haskell 的整個社群還更成熟一些,人們對其他方法的态度也比較友善,隻是将 Haskell 視為“能解決适當問題的有趣工具”。

另一方面,Rust 在表達其偏好時往往偏執得像個叛逆期的青少年。他們的話語斬釘截鐵,而且不願讨論更多細微差别。程式設計是種近乎玄學的微妙工作,人們往往需要經曆一系列權衡、做出很多次優選擇才能及時得到結果。Rust 生态系統中盛行的完美主義堅持和對“正确方式”的癡迷,常常讓我感覺它是不是引來了很多剛接觸程式設計的人,一聽說某種理論就深信不疑。再次強調,我知道這種情況并不适用于所有人,但我認為對 ECS 的整體癡迷恐怕就是這麼來的。

泛型系統無法實作有趣的遊戲玩法

為了防止前面提到的這種種情況,一種常見的解決方案就是對系統進行全面泛型化。隻要以更細粒度的方式劃分元件并使用适當 系統,那麼所有這些特殊問題肯定都可以被避免,對吧?

除了“泛型系統無法實作有趣的遊戲玩法”之外,我也确實拿不出太多有力的反對論據。我在 Rust 遊戲開發社群非常活躍,也見過很多由其他人開發的項目,他們提出的建議往往跟正在開發的遊戲高度相關。而那些設計出巧妙、具備完全通用操作屬性的系統,往往并不是真的在做遊戲。程式設計成了對遊戲邏輯的“模拟”,導緻“建立一個可以移動的角色”就成了遊戲玩法本身,其核心重點往往表現為以下一條或者多條:

  • 由程式生成的世界、行星、空間、地下城。
  • 基于體素的任何内容,重點關注體素本身、渲染體素、世界大小和性能。
  • 互動通用化,即“任何東西都可以與其他任何東西實作 xx”。
  • 以盡可能最優方式進行渲染,“做遊戲怎麼可以不用 draw indirect 呢?”
  • 為遊戲建構設計出良好的類型和“架構”。
  • 建構一套引擎來制作更多類似的後續作品。
  • 考慮多人遊戲需求。
  • 使用大量 GPU 粒子,認為粒子越多則視覺效果越好。
  • 寫出結構良好的 ECS 和幹淨的代碼。
  • 諸如此類……

就技術探索和學習 Rust 而言,這些都是很不錯的目标。但我還是想要重申本文開頭提到的原則:我并不是想要磨練技術,或者以做遊戲的方式來學習 Rust。我的目标就是開發獨立商業遊戲,在合理的時間把它賣給盡可能多的玩家,保證他們願意為此付費并靠人氣登上 Steam 首頁推薦。請别誤會,我也不是要為了賺錢而不異一切代價,這篇文章就是從一位認真的遊戲開發者角度出發,聊聊除技術之外,Rust 是怎麼慢慢消磨掉一位從業者對于遊戲、玩法和玩家的關注的。

對技術的熱情當然沒有錯,但我認為大家最好認真想想自己到底是在追求什麼目标,特别是要以坦誠的态度看待自己的初衷。有時候,我覺得某些項目其實已經走偏了,把本身的意義扭曲成了能夠展現出多少技術意義。這不正常,至少在我這位嚴肅的遊戲開發者看來,這不正常。

現在回到泛型系統。我認為以下幾點原則可以創造出優秀的遊戲,但卻以直接或間接的方式違背了泛型 ECS 方法:

  • 大部關卡為手動設計。這與“線性”或者“叙事”無關,隻是在強調“如何用引導的方式對玩家行為做出控制”。
  • 在各個關卡中精心設計每一項互動。
  • 視覺特效并不等于大量粒子,而是在所有遊戲系統上運作的時間同步事件(例如,将多個不同 emitters 以手動設計的排程機制觸發)。
  • 反複對遊戲進行測試,對遊戲功能進行多輪驗證,實驗并丢棄不好玩的部分。
  • 盡快将遊戲傳遞給玩家,以便進行測試和疊代。畢竟釋出的時間越晚,上架後玩家們的熱情就越弱。
  • 務必要提供獨特且難忘的遊玩體驗。

我知道,讀到這裡,很多朋友會覺得我想要提那種充滿藝術氣息的遊戲,而不是像《異星工廠》那種極具工程師氣質的作品。絕對不是,我喜歡那種系統設定極強、有着強烈代碼美感的遊戲,我也願意做一些由程式設計驅動的東西,畢竟我自己也是個不折不扣的程式員。

我覺得大多數人犯的錯誤,就是誤以為認真設計玩家互動就是在搞藝術創作。不是的,認真設計玩家互動就是遊戲開發的本質。遊戲開發不是在建立實體模型,不是在設計渲染器,更不是搞什麼遊戲引擎或者場景樹,自然也不是帶有資料綁定的響應式 UI。

這方面的正面案例當數《以撒的結合》,這是一款簡單到看似簡陋的“肉鴿”類遊戲,包含數百種更新。這些更新項目會以非常複雜精妙的互動方式改變遊戲體驗。注意,它沒有使用“+15%傷害”之類簡單粗暴的更新機制,而是提供“讓炸彈粘在敵人身上”、“将子彈轉換為雷射”或者“在每個關卡中殺死的第一個敵人,永遠不會在後續關卡中出現”之類的巧妙選項。

乍看之下,用預先準備的泛型系統也可以設計出這樣的遊戲,但這又是我覺得大多數人在遊戲開發中犯的另一個錯誤。遊戲開發不是這樣的,我們不可能把自己關在小黑屋裡整整一年、在考慮了所有極端情況後建構起一套泛型系統,然後指望着它能展現這樣一款優秀遊戲的全部需求。不是的,我們隻能先用少量機制開發出一個原型再交給大家遊玩,看看核心機制是否有趣,之後添加點新設計再去收集回報。其中一些互動隻有在玩了幾個小時的早期版本并嘗試了不同操作之後,才能憑借對遊戲的深入認知而得到。

Rust 語言的設計則完全背離這樣的邏輯,任何新型更新都可能迫使我們重構所有系統。很多人可能會說,“那太好了,現在我的代碼品質更高了,可以容納更多功能!!!”好吧,這話看似沒錯,我也已經聽過無數遍了。但我想提醒大家,作為一線遊戲開發者,Rust 的這種毛病已經導緻我浪費了大量時間,隻為給錯誤問題找個所謂的合理答案。

其他更靈活的語言則允許遊戲開發者以一種簡單粗暴的方式實作新功能,然後馬上讓遊戲跑起來,看看這種機制是否真的有趣。這樣我們就能在短時間内快速疊代。就在 Rust 開發者還在重構的時候,C++/C#/Java/JavaScript 開發者已經實作了一大堆新的遊戲功能,通過試玩進行了體驗,而且更好地認識到自己的作品應該朝着哪個方向發展。

Jonas Tyroller 在他關于遊戲設計的視訊教程中對此做過解釋,我也推薦每位遊戲開發者都能認真看看。如果各位不知道自己做的遊戲為什麼就是不好玩(也包括我自己),那答案很可能就在其中。一款好的遊戲不是在實驗室環境下硬憋出來的,而是由相應類型的高手玩家回報出來的。遊戲制作者本身也應該是個遊戲好手,了解設計的各個方面,而且在拿出最終成果之前也體驗過很多失敗設計。總而言之,一款優秀的遊戲就是在非線性的過程中嘗試各種糟糕想法,最終篩選和打磨出來的。

Rust 遊戲開發生态純屬炒作的産物

Rust 遊戲開發生态還很年輕,我們在社群内交流時,大家也普遍承認這一點。至少到 2024 年,社群成員們已經能夠坦然接受自己不夠成熟的現實。

但從外部視角來看,情況則完全不同,這主要歸功于 Bevy 等項目的出色營銷。就在幾天之前,Brackeys 釋出了他們回歸 Godot 進行遊戲開發的視訊。我第一時間看了視訊,并對其中提到的令人驚歎的開源遊戲引擎抱有極高期待。到 5:20 左右,視訊展示了一張遊戲引擎市場的份額圖,我非常震驚地看到其中列出了三款 Rust 遊戲引擎:Bevy、Arete 和 Ambient。

現在我想要特别澄清這一點。更确切地說,我感覺 Rust 本身已經成了一種符号、一種網絡 meme,就跟表情動圖那樣成了人們站隊和調侃的素材。這樣不好。

Rust 生态系統的正常運作方式,就是一定要高調宣傳那些敢于做出最多承諾、展示最漂亮的網站/自述檔案、擁有最華麗 gif,最重要的就是更能表現抽象價值觀的素材。至于實際可用性如何?那都不重要。其實也有很多人在默默做實事,他們不會承諾那些可能永遠無法實作功能,而隻是嘗試以一種有效的方式解決一個問題,但由于不夠“性感”、這些項目幾乎從來不會被提及,哪怕是出現之後也隻被視為“二等公民”。

這裡最典型的例子就是 Macroquad。這是一套非常實用的 2D 遊戲庫,幾乎可以在所有平台上運作,提供非常簡單的 API,編譯速度極快且幾乎沒有依賴項,最誇張的就是由一個人建構而成。還有一個附帶的庫 miniquad,負責在 Windows/Linux/MacOS/Android/iOS 和 WASM 上提供圖形抽象。然而,Macroquad 犯下了整個 Rust 生态中最嚴重的“罪行”之一——使用全書狀态,甚至可能不健全。這裡我說的是“可能”,而且在較真的人看來這種描述并不準确,因為無論出于何種意圖和目的,它都仍然是完全安全的,除非你打算在 OpenGL 場景下使用最低級别的 API。我自己使用 Macroquad 已經快兩年,從來沒遇到過問題。但就是這麼一套出色的庫,每當被人提起時招來的都是無情的嘲諷和打擊,理由就是它符合 Rust 的價值主張——100%的安全性和正确性。

第二個例子是 Fyrox,這是一款 3D 遊戲引擎,擁有完整的 3D 場景編輯器、動畫系統以及制作遊戲所需要的一切。這個項目同樣由單人制作完成,他還利用該引擎開發了一款完整的 3D 遊戲。就個人而言,我沒有用過 Fyrox,我承認我自己也被那些漂亮的網站、大量 GitHub stars 和誇張的炒作之詞蒙蔽了雙眼。Fyrox 最近在 Reddit 上倒是獲得了一些關注,但讓我難過的是,盡管提供完整的編輯器,但它幾乎從未在任何宣傳視訊中被提及——反倒是 Bevy 總是沒完沒了地露面、刷存在感。

第三個例子是 godot-rust,屬于是 Godot Engine 的 Rust 捆綁包。這個庫最犯罪的“罪行”,在于它并不屬于純粹的 Rust 解決方案,而隻是指向肮髒 C++引擎的捆綁包。我說的可能有點誇張,但從外部視角來看,Rust 社群基本就是這麼個誰也瞧不上的德性。Rust 是最純淨的、是最正确的、也是最安全的;C++則是糟糕的、陳舊的、醜陋的、危險的、複雜的。也正因為如此,我們不會在 Rust 遊戲開發中使用 SDL,因為我們有 winit;我們不用 OpenGL,因為我們有 wgpu;我們不用 Box2D 或者 PhysX,因為我們有 Rapier;我們還有用于遊戲音頻的 kira;我們不用 ImGUI,因為我們有 egui。最重要的是,我們絕對不用 C++編寫出來的原有遊戲引擎,這将亵渎至高無上的“螃蟹”代碼大神!任何想要用 rustup default nightly 加快編譯速度的開發者,都必須與“螃蟹”簽訂這神聖的契約。

如果有人想要認真用 Rust 開發一款遊戲,特别是 3D 遊戲,那我的第一建議就是使用 Godot 和 godot-rust,因為它們至少提供一切必要的功能、而且是真正能傳遞作品的成熟引擎。我們這個小組花了一年暗用 Godot 3 開發出了 BITGUN,又用 godot-rust 建構出了 gdnative。雖然這段經曆确實痛苦非常,但這并不是捆綁包的錯,而是我們一直在想辦法以各種動态方式把 GDScript 和 Rust 混合在一起。這是我們第一個、也是最大的 Rust 項目,更是我們選擇 Rust 的原因。但我想告訴大家,之後我們用 Rust 制作的每一款遊戲都并不是遊戲,而成了解決 Rust 語言技術缺陷、生态匮乏或者設計決策難題的實踐課,且全程受到語言僵化特性的折磨。我并不是說 GDScript 和 Rust 間的互操作很簡單,絕對不是。但至少 Godot 提供了“擱置問題、姑且繼續”的選項。我覺得大多數選擇純代碼方案的開發者都不重視這一點,特别是在 Rust 生态當中,而這種語言真的在用各種各樣的别扭設計毀滅我的創造力。

關于 Ambient,我倒是沒有太多想說的。畢竟這是個新項目,而且我自己也沒用過。但我也沒聽說過其他人用過,而它卻出現在了 Brackeys 的宣傳視訊當中。

Arete 幾個月前釋出了 0.1 版本,但由于其聲明非常模糊且為閉源代碼,是以在 Rust 社群中激起了比較負面的評價。盡管如此,我還是在很多場合看到外人提過它,主要是因為主創團隊比較敢“吹”。

至于 Bevy,我當然相信它作為“主要”Rust 遊戲引擎的合理性,至少在項目規模和參與人數上絕對堪稱主流。他們成功建立起了龐大的技術社群,雖然我不一定同意他們的承諾和上司層的某些選擇,但我也必須承認 Bevy 的确很受歡迎。

這一節想聊的,就是讓大家感受到 Rust 社群那種奇怪的狀态。Rust 以外的朋友看到這些引擎的營銷内容和公告博文很可能會信以為真,但我自己也不止一次相信過他們、聽到過似乎極具說服力的話,但後來卻發現他們隻是擅長鬼扯、在實際功能傳遞上做得很差。

另外值得一提的 是,Rapier 本身并不是遊戲引擎。這是一套廣受好評的實體引擎,但有望在實體效果層面成為 Box2D、PhysX 等方案的純 Rust 替代選項。畢竟 Rapier 是用純 Rust 編寫的,是以享有 WASM 支援的所有優勢,速度極快、并行核心而且非常安全……大概是吧。

我對它的判斷主要源自 2D 應用,雖然基本功能确實有效,但不少更進階的 API 則從根本上存在問題——例如凸分解會在相對簡單的資料上崩潰、删除多體關節時也可能導緻崩潰。後面這情況特别有趣,因為這讓我懷疑自己難道是第一個嘗試删除關節的人?這也不是多罕見或者說多極端的用法吧?但總的來說,我還發現 Rapier 的模拟效果極不穩定,并最終迫使我編寫了自己的 2D 實體引擎,而且至少在個人測試中發現在“防止敵人重合”等簡單問題上表現更好。

我并不是在宣揚自己的實體效果庫,因為它沒有接受過全面測試。關鍵在于,如果 Rust 新手想要一套實體引擎,那社群大機率會向其推薦 Rapier,很多人會說這是一套很棒且大受歡迎的庫。它還有個很漂亮的網站,在社群中廣為人知。行,我承認可能隻是個人問題,但我覺得它不好用、甚至為此自己重搞了一套。

不少 Rust 生态項目都有個共性,那就是用 PUA 的方式讓使用者感覺是自己的錯——他們就不該考慮某個問題,就不該用某種方式建構某種功能。這種感覺就類似于使用 Haskell 并想要實作副作用……“你就不該這麼幹”。

但奇怪的是不隻 Rust,一般讓使用者有這種感覺的庫往往會獲得普遍贊揚和認可,這可能是因為大多數生态項目都依賴于炒作,而非項目的真實傳遞效果。

全局狀态很煩人/不友善,遊戲還是單線程的

我知道,隻要說起“全局狀态”,很多人就會馬上意識到這是個嚴重的錯誤。而這也正是 Rust 社群為項目/開發者制定的極其有害且不切實際的規則之一。不同項目之間的需求差別很大,至少在遊戲開發這類場景下,我覺得很多人其實都沒意識到自己到底在解決什麼問題。對全局狀态的“仇視”也是有範圍的,大多數人并不是要 100%反對,但我仍然覺得 Rust 社群在這個問題上走錯了方向。再次重申,我們要讨論的不是引擎、工具包、庫、模拟什麼的,我們讨論的是遊戲作品。

就一款遊戲而言,隻有一個音頻系統、一個輸入系統、一個實體世界、一個 deltaTime、一個渲染器、一個資源加載器。也許在某些極端情況下,不用全局狀态可能會稍微誰一些;而且如果正在制作基于實體引擎的多人線上遊戲,要求可能也會有所不同。但大多數人開發的要麼是 2D 平台跳躍遊戲、要麼是豎版射擊遊戲,或者是基于體素的步行模拟遊戲。

在經曆了多年把所有内容都作為參數注入的“純淨”方法(從 Bevy 0.4 開始一路到 0.10),還嘗試過建構自己的引擎,我對這種純全局的設計深惡痛絕,而且播放聲音隻能靠 play_sound("beep" )。沒錯,就是深惡痛絕。

我倒不是要專門針對 Bevy,而是發現整個 Rust 生态很大程度都犯了這個錯誤,唯一的例外就是 Macroquad。而這裡之是以以 Bevy 舉例,就是因為它天天在那刷存在感。

以下這些都是我經常在 Comfy 中使用的遊戲開發功能,它們都用到了全局狀态:

  • play_sound("beep") 用于播放一段音效。 如果需要更多控制,可以使用 play_sound_ex(id: &str, params: PlaySoundParams)。
  • texture_id("player") 用于建立 TextureHandle 來引用資源。沒有可用于傳遞的資産伺服器,在最差的情況下我得使用路徑作為辨別符;而且由于路徑是唯一的,是以辨別符也是唯一的。
  • 用于繪制的 draw_sprite(texture,position,...)或 draw_circle(position,radius,color)。由于每種嚴肅引擎都必然會批量繪制調用,是以它們都沒辦法把繪制指令推入隊列來實作更複雜的功能。我真心希望能有個全局隊列,這樣我才能在需要畫圈的時候随心所欲畫個圈。

身為 Rust 開發者(不一定是遊戲開發者),大家在閱讀本文的時候可能會想,“那線程呢?”沒錯,這也是 Bevy 伺服器的一個好例子。因為 Bevy 提出了這個問題并嘗試用最通用的方式解決,是以我們自然好奇如何讓所有系統都并行運作會怎樣。

這是個很合邏輯的推論,對于很多剛接觸遊戲開發的朋友來說也是個好辦法。因為就跟後端一樣,讓一切以異步形式運作線上程池之上,似乎能輕松帶來更好的性能。

但遺憾的是,我覺得這也是 Bevy 犯下的最大錯誤之一。很多 Rust 開發者逐漸意識到(雖然很少有人願意坦然承認),Bevy 的并行系統模型非常靈活,即使是在跨架構情況下也無法保持一緻的順序(至少我上次嘗試的時候是這樣)。如果要維持排序,就必須指定一個限制。

這乍看下來似乎合理,但在多次嘗試在 Bevy 下開發一款大體量遊戲(開發周期達幾個月,涉及數萬行代碼)後,最終情況就是開發者不得不指定一大堆依賴項,因為遊戲中的事物往往需要以特定順序發生,以避免因某些内容先運作在随機造成的丢幀甚至是意外錯誤。但千萬别想着跟社群提這事,因為你馬上會被口水吞沒。Bevy 的設計在技術層面完全正确,隻是在真正将其用于遊戲開發時,總會引發這樣或者那樣的問題。

那現在這種設計肯定也有好處吧?比如說可以并行的部分能讓遊戲運作得更快?

不好意思,在投入大量工作對系統進行嚴格排序之後,已經沒剩下多少能夠并行化的東西了。在實踐層面,這相當于是對純資料驅動系統做并行化,隻為換取一點點性能提升。這根本不值得,而且用 raycon 也能輕松實作。

回顧這麼多年來的遊戲開發曆程,我用 Burst/Jobs 在 Unity 中編寫的并行代碼要比自己在 Rust 中實作的多得多。無論是在 Bevy 中還是在自定義代碼當中,我的大部分精力都被耗費在了技術上,導緻很少有時間能認真考慮怎麼讓遊戲變得更好玩。不開玩笑,我總是在跟語言作鬥争、或者圍繞語言特性做設計,至少確定不會因為 Rust 的某些“怪癖”而嚴重破壞開發體驗。

全局狀态就是其中的典型。我知道這節已經很長了,但我覺得确實有必要進一步解釋。讓我們先對問題做出明确定義。Rust 作為一種語言,通常提供以下幾種選項:

  • static mut,不安全,是以每次使用都需要 unsafe,可能在意外誤用時會導緻 UB。
  • static X:AtomicBool(或 AtomicUsize,或任何其他受支援的類型)……一個不錯的解決方案,雖然還是煩人,但至少用起來還行,但僅适用于簡單類型。
  • static X: Lazy<AtomicRefCell<T>> = Lazy::new(|| AtomicRefCell::new(T::new()))……這對大多數類型來說都是必需的,而且不僅在定義和使用方面煩人,還會由于雙重借用而導緻運作時潛在崩潰。
  • ……當然還有“直接傳遞,别用全局狀态”。

我已經記不清有多少次因為雙重借用而意外導緻崩潰了,這并不是因為代碼“在設計之初就很差勁”,而是因為代碼庫中的其他部分強制進行了重構。在此過程中,我不得不重構對全局狀态的使用,并導緻了意外崩潰。

Rust 使用者可能會說,歸根結底這是因為我的代碼出錯了,而 Rust 是幫我發現了 bug。是以才說全局狀态不好,應該盡量避免。說的有理,這種檢查也确實能在一定程度上預防這些 bug。但結合我在使用 C#等簡單全局狀态的語言時遇到的實際問題,我想提醒大家,至少在遊戲開發這類場景下,代碼中其實很少會出現這些問題。

另一方面,使用動态借用檢查進行任何操作時,由于雙重借用而導緻的崩潰真的很容易發生,而且通常是源自不必要的理由。其中一例就是對 ECS 重合的原型進行查詢。這裡給不熟悉 Rust 的朋友說說,以下代碼在 Rust 裡其實是有問題的(出于可讀性而進行了簡化):

for (entity, mob) in world.query::<&mut Mob>().iter() {
  if let Some(hit) = physics.overlap_query(mob.position, 2.0) {
    println!("hit a mob: {}", world.get::<&mut Mob>(hit.entity));
  }
}           

問題在于,我們在兩個位置上接觸到同一個東西。更簡單的例子是通過執行類似的操作來對兩個東西進行疊代(同樣進行了簡化):

for mob1 in world.query::<&mut Mob>() {
  for mob2 in world.query::<&Mob>() {
    // ...
  }
}           

Rust 的規則禁止對同一對象設定兩個可變引用,憑借可能導緻這種情況的行為均不被允許。在上述情況下,我們會遇到運作時崩潰。某些 ECS 方案可以解決這個問題,例如在 Bevy 當中,當查詢不相交時,至少可以進行部分重合,例如 Query<(Mob, Player)> 和 Query<(Mob, Not<Player>)>,但這隻能解決沒有重合的情況。

我在關于全局狀态的部分也提過這一點,因為一旦事物變得全局化,那麼這種限制将變得特别明顯,而且很容易意外導緻代碼庫中的其他部分以某種全局引用方式觸發 RefCell<T>。再次強調,Rust 開發者會覺得這沒問題,因為能預防潛在 bug!但我還是堅持認為,這并沒有幫上什麼忙,而且我在使用沒有此類限制的語言時也沒遇到過由此導緻的問題。

再就是線程問題,我認為最大的誤區就是 Rust 遊戲開發者往往認為遊戲跟後端服務是一回事,所有内容都必須異步運作才能保持良好狀态。在遊戲代碼中,内容最終必須被打包在 Mutex<T> 或 AtomicRefCell<T> 中,進而“避免像 C++程式設計時那樣忘記同步通路可能引發的問題”。但這實際上隻是在滿足編譯器對于線程安全的堅持,哪怕整個代碼庫中沒有一個 thread::spawn。

動态借用檢查導緻重構後意外崩潰

就在寫這篇文章的時候,我剛剛發現了另一個由于 World::query_mut 重合而導緻遊戲崩潰的問題。我們使用 hecs 已經有快兩年了,是以問題的根源絕對不是剛開始使用這個庫時那種“我不小心嵌套了兩個查詢”之類的小問題。相反,該代碼中有一部分頂層在運作着執行某些操作的系統,而代碼的獨立部分則在深層使用 ECS 執行某些簡單操作。在經過大規模重構之後,它們最終總會意外重合。

這已經不是我第一次遇到這種情況了,通常的建議解決方案就是“你的代碼結構太差,是以才會遇到這些問題。你應該重新并調整設計思路。”我不知道該怎麼反駁,因為從道理上講人家說得對,發生這種情況确實是因為代碼庫中的某些部分設計不理想。但問題是,最終引發崩潰的是 Rust 的強制重構,其他語言根本不會這樣。原型重合又不是犯罪,像 flecs 這樣的非 Rust ECS 解決方案對此就非常寬容。

但這個問題并不僅限于 ECS。我們在使用 RefCell<T>也曾反複遇到過同樣的情況。其中兩個.borrow_mut()最終重合并導緻意外崩潰。

讓人難以接受的是,引發崩潰的并不隻是因為“代碼品質太差”。社群的建議一般是“盡量少借用”,但這本質上還是在強調要以正确方式建構代碼。而我做的是遊戲開發,又不是伺服器開發,不可能總是把所有時間和精力都放在代碼組織身上。是以,有時會有一個循環要用到 RefCell 中的某些内容,是以把借用擴充到整個循環又有什麼錯了?但隻要循環稍微大一點,并調用到了内部需要相同 cell 的某個系統(通常會帶有一些條件邏輯),就很可能立刻引發問題。支援者會說“應該用間接并通過事件執行有條件操作”,但我們說的是可是散布在整個代碼庫當中的遊戲邏輯,而不隻是短短 10 行、20 行簡單易讀的代碼。

在完美的世界中,所有内容都将在每次重構時進行測試,每個分支也都将進行評估,代碼流既線性又自上而下——但這樣的情況永遠不存在。而實際情況是,哪怕我們壓根不用 RefCell,也得認真設計它們的函數,以便它們能夠傳遞正确的上下文對象或者僅傳遞所需的參數。

而這一切,對于獨立遊戲開發來說根本不現實。對那些可能在幾天後就被删除的功能進行重構純粹是浪費時間,這也使得 RefCell 成為部分借用的了解杜門謝客。否則我們就必須把資料重新組織成不同形态的上下文結構、更改函數參數或者用間接方法把事物區分開來。

上下文對象不夠靈活

由于 Rust 對程式員有着一套相對獨特的限制要求,是以往往會引發很多獨有的問題,而這些問題在其他語言中很可能并沒有相應的解決方案。

其中一例就是傳遞上下文對象。在幾乎所有其他語言當中,引入全局狀态都不是個大問題,無論是以全局變量還是單例的形式。但出于以上種種原因,Rust 再次把簡單問題給複雜化了。

人們提出的第一種解決方案就是“隻存儲對心生需要的任何内容的引用”,但任何具有一定 Rust 經驗的開發者都會意識到,這根本就不可能。借用檢查器會要求每個引用字段都跟蹤其生命周期,而且由于生命周期會成為泛型并污染該類型的每個使用點,是以我們甚至沒辦法輕松進行實驗。

還有另一個問題,這裡我覺得也有必要明确聊聊,畢竟經驗不多的 Rust 開發者可能根本沒注意到。從表面上看,“我就隻使用生命周期”似乎也不會怎樣:

struct Thing<'a>
  x: &'a i32
}           

可問題是,如果現在我們需要一個 fn foo(t: &Thing)……結論是不行,因為 Thing 在整個生命周期中都是泛型,是以必須将其轉換成 fn foo<'a>(t: &Thing <'a>或者更糟的形式。如果我們嘗試将 Thing 存儲在另一個結構中,那麼最終得到的就是:

struct Potato<'a>,

size: f32,

thing: Thing<'a>,

}

盡管 Potato 可能并不會真受 Thing 的影響,但 Rust 對其生命周期還是會嚴肅對待,強迫我們加以重視。而且實際情況比看起來更糟,因為哪怕發現了問題并非想要擺脫,Rust 也不允許存在未使用的生命周期,于是乎:

struct Foo<'a> {

x: &'a i32,

}

而在重構代碼庫時,我們最終希望将其更改為:

struct Foo<'a> {

x: i32,

}

這樣肯定不行,因為會存在一個未使用的生命周期。這看起來不是太大的問題,而且在其他語言中往往同樣存在,但問題是 Rust 的生命周期通常需要大量“問題解決”和“調試”過程。比如說我們可能會做各種嘗試,對于生命周期就是添加和删除。而删除生命周期對 Rust 來說意味着不再使用,那就必須在所有位置把它全都删掉,進而導緻大規模級聯重構。多年以來,我曾多次遇到過這樣的情況,老實講,最讓人抓狂的就是隻想用生命周期疊代的方式完成一項非常簡單的變更,最後卻被迫更改了 10 個不同的位置。

而且哪怕在其他情況下,我們也沒法單純“存儲對某事物的引用”,因為生命周期不允許。

Rust 在這裡提供一種替代方案,就是以 Rc<T> 或 Arc<T>的方式共享所有權。能行,但往往會激發強烈的反對。是以在使用 Rust 一段時間之後,我意識到最好的辦法就是悄悄用、别聲張。沒必要跟那幫 Rust 鐵粉坦白,假裝沒這回事就好了。

遺憾的是,在很多情況下這種共享所有權也不是什麼好辦法,比如說出于性能的考慮,有時我們根本無法控制所有權、隻能擷取引用。

Rust 遊戲開發的頭号技巧就是,“如果在每幀中自上而下傳遞引用,那麼所有生命周期/引用問題都會消失”。沒錯,這招非常有效,就類似于 React 的自上而下傳遞 props。唯一的問題就是,現在我們需要把所有内容傳遞到每一個需要它的函數當中。

這乍看起來并不困難,隻要正确設計代碼,就不會有任何問題。嗯,很多人都這麼說,但我真不知道他們自己寫的代碼永遠正确,還是故意拿這個标準出來惡心别人。反正你懂的……

好在還有個辦法,就是建立一個用于傳遞的 conetxt struct 并包含所有引用。這雖然還是有相應的生命周期,但至少隻有一個,實際如下:

struct Context<'a> {

player: &'a mut Player,

camera: &'a mut Camera,

// ...}

這樣遊戲中的每個函數都能接收一個簡單的 c: &mut Context 并擷取它需要的内容。這就很棒了,對吧?

但前提就是,我們不能借用任何東西。想象一下,如果我們想要運作一個玩家系統,但同時要維持住鏡頭内容,這時候 player_system 也需要 c: &mut Context,因為我們希望保持一緻并避免将 10 個不同的參數都傳遞過去。可在這樣做的時候:

let cam = c.camera;
 
player_system(c);
 
cam.update();           

在觸及一個字段時,往往會遇到“無法借用 c,因為其已經被借用”的問題,而且部分借用規則明确提到,在觸及某個對象時,其整體都會被借用。

如果 player_system 隻接觸 c.player 倒是沒關系,Rust 不會關心其中的具體内容,它隻關心類型。而類型說它想要 c,是以必須得擷取 c。這個例子看起來有點蠢,但在那些上下文對象比較大的成規模項目中,我們确實經常遇到想在某個地方使用某些字段的子集、同時又想便捷地将其餘字段傳遞至其他地方的情況。

但 Rust 的開發者當然也不傻,它允許我們執行 player_system(c.player),因為部分借用允許我們借用不相交的字段。

是以,那幫支援借用檢查器的開發者就會說,我們是設計了錯誤的上下文對象,應該将其拆分成多個上下文對象,或者根據字段的用途對字段進行分組,以便發揮部分借用機制。比如說把所有鏡頭内容都放進同一個字段,再把所有跟玩家相關的内容放進另一字段,之後就可以将該字段傳遞給 player_system,而非傳遞整個 c。這樣不就解決了?

沒那麼簡單,再次回到文章開頭,我說了我隻是想開發遊戲。我做這事不是想要鼓搗類型系統,也不是為了找到最好的結構組織方式來滿足編譯器的要求。在重新組織上下文對象時,我在單線程代碼的可維護性方面沒有任何收獲。而且在經曆了無數次這種情況後,我可以負責任地講,在下一次進行遊戲測試并收集回報時,我很可能還得再來一次。

這個問題的實質,就是盡管代碼沒有變更,但由于業務邏輯發生了變化,是以編譯器出于過于嚴苛的要求而強制重構代碼。具體問題可能是沒有遵循借用檢查器的工作方式,而且隻關注類型的正确性。隻要我們傳遞目前正在使用的所有字段,那編譯過程就沒有問題。也就是說,Rust 強迫我們在傳遞 7 個不同的參數與随時重構代碼結構之間二選其一。這兩個選項都很煩人,而且純屬浪費時間。

Rust 沒有結構類型系統,也就是所謂“擁有這些字段的類型”,也沒有任何其他無需重新定義結構及相關内容就搞定問題的解決方案。它隻堅持一點:強迫程式員做“正确”的事。

Rust 的優點

整篇文章看下來,好像我把 Rust 批判得一無是處。但在這一節中,我想列舉我從 Rust 中發現的積極因素,它們确實在開發過程中幫了我的忙。

隻要能成功編譯,代碼就是正常運作。這是 Rust 的金字招牌,也打消了我原本對“編譯器驅動開發”這個理念半信半疑的态度。迄今為止,Rust 最大的優勢就是隻要人們能編寫出适合的代碼,那一切都會順利運作,該語言也會用種種規則引導使用者選擇正确的編寫方式。

從我個人角度來看,Rust 最大的優勢展現在開發 CLI 工具、資料操作和算法上。我花了很多時間來編寫“Rust 版的 Python 腳本”,其中包括大多數人經常用到的 Python 或者 Bash 小型實用程式。這既是在實際開發、也是個學習的過程,而且令我驚訝的是這确實有效。我絕對不想在 C++裡做同樣的嘗試。

預設強調性能。說回 C#,這裡我們要更細粒度的層面上研究 Rust 跟 C#的性能差別。比如嘗試在兩種語言編寫同一種特定算法,看能不能提到同樣的性能。而且盡管在 C#那邊多下了點心力,但 Rust 仍以 1:1.5 到 2.5 的優勢勝出。對于經常跑基準測試的朋友來說,這個結果似乎在預料之中。但在自己親身經曆過之後,我真的對随意編寫的 Rust 代碼居然如此之快深感震驚。

另外我想指出的是,Unity 的 Burst 編譯器大大提高了 C#的性能。但我并沒有充足的 A/B 資料來提供具體結論,隻能說是觀察到 C#代碼明顯跑得更快了。

話雖如此,在使用 Rust 的這些年中,我也一直對其代碼的運作效果感到驚喜。我注意到,這一切都基于 Cargo.toml 中的以下内容:

[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 1           

我看到有很多人都詢問自己的代碼為什麼跑得很慢,但結果發現他們是在做 debug build。正如 Rust 在開啟優化時速度很快一樣,在關閉優化後也會速度大降。這裡我使用的是 opt-level = 1 而不是 3,因為我在測試中并沒注意到運作性能有什麼差異,但在我的測試代碼上 3 的編譯速度明顯更慢。

枚舉的實作也很漂亮。每位用過 Rust 的朋友應該都有感受,随着時間推移,我更傾向于使用更動态的結構,而不再選擇嚴格的枚舉與模式比對。但至少在枚舉更适合的情況下,其效果确實不錯,而且幾乎是用過的語言中我最喜歡的實作。

Rust 分析器。我不确定這到底算優點還是缺點,這裡姑且放在優點裡吧。畢竟如果沒有它,我 100%寫不出 Rust 代碼。自從 2013 年左右首次接觸 Rust 以來,這款工具已經迎來顯著改進,在實踐層面的效果也是非常非常好。

而之是以考慮把它放進缺點裡,是因為它仍然是我用過的最糟糕的語言伺服器之一。我知道這是因為 Rust 本身非常複雜,而且我自己的項目也情況特殊(這可能是我的錯),是以它的崩潰往往屬于個例(我一直在保持更新,但還是會在各種裝置/項目上崩潰)。但盡管如此,分析器還是非常有用、幫助極大,成為我 Rust 開發經曆中不可或缺的好助手。

Traits。雖然我并不造成完全消除繼承,但也承認 trait 系統相當棒,而且非常适合 Rust。如果能對孤兒原則稍微放松一點的話,那就更好了。盡管如此,能夠用上擴充 traits 是 Rust 語言中最讓我開心的感受之一。

寫在最後

自 2021 年年中以來,我們基本在所有遊戲上都在使用 Rust。BITGUN 最初隻是作為 Godot/GDScript 項目,之後我們在 Godot 上遇到了尋路問題(性能和功能都不理想),于是我開始研究替代方案,并相繼找到 gdnative 和 godot-rust。這已經不是我第一次接觸或者使用 Rust,但卻是在遊戲開發中第一次嚴肅使用 Rust——在此之前隻在 game-jam-y 項目中用過。

從那時起,Rust 成了我唯一堅持使用的語言。我對建構自己的渲染器/架構/引擎之類感到莫名興奮,而 Comfy 的早期版本也由此誕生。接下來又發生了很多事,包括支援 CPU 光線追蹤的小遊戲 jam,到嘗試簡單的 2D IK、編寫實體引擎、實作行為樹、實作以單線程協程為中心的異步執行器,再到構模組化拟 NANOVOID 乃至 Unrelaxing Quacks,也就是我們釋出的第一款、也是最後一款 Comfy 遊戲。Unrelaxing Quacks 才剛剛在 Steam 上架,讀到本文的時候大家應該就能玩到了。

這篇文章的感受,主要來自我們在開發 NANOVOID 和 Unrelaxing Quacks 兩款遊戲時的掙紮曆程。畢竟到這個時候,我們已經不像最初開發 BITGUN 時那樣缺乏 Rust 使用經驗了,是以各種問題才顯得尤其難以忍受。在此之前,我們還多次使用過 Bevy——BITGUN 是我們嘗試移植的第一款遊戲,Unrelaxing Quacks 則是最後一款。在開發 Comfy 的兩年時間裡,我們重寫了渲染器,先是從 OpenGL 到 wgpu,然後又從 wgpu 回到 OpenGL。截至本文撰稿時,我已經擁有 20 年左右的程式設計經驗,最早是從 C++開始,之後嘗試過包括 PHP、Java、Ruby、JavaScript、Haskell、Python、Go、C#在内的各種語言,還在 Steam 上釋出過 Unity、虛拟 4 還有 Godot 開發的遊戲。我是那種喜歡嘗試各種方法的人,樂于積極探索并體驗一切。按大多數人的标準來看,我們的遊戲可能并不是最好的,但我們已經在自己的能力範圍内做了一切嘗試,努力找到最優解決方案。

我說這一切,是想讓大家知道我們已經為 Rust 付出了足夠的努力和耐心,這篇文章絕不是出于無知或者經驗不足。聯想起每當有人就 Rust 提出問題,總有人半開玩笑地回應說“你覺得 Rust 不好用,是因為你的經驗還不夠”。并不是,我們反複實驗過高度動态和純靜态的方法,試過純 ECS 也嘗試過無 ECS,真的。

原文連結:Rust生态純屬炒作?3年寫了10萬行代碼開發者吐槽:當初用Rust是被忽悠了_程式設計語言_InfoQ精選文章

繼續閱讀