可以,而且我會把它整理成一個更像「技術社群讀者一看就知道這個移植到底厲害在哪裡」的版本,而不是單純把功能列出來。

Tyrian 移植到 GBA:不只是遊戲移植,而是一場完整的效能工程

TyrianGbaPoc 一開始其實是在回答一個很直接的問題:

能不能把 DOS 時代的 Tyrian 搬到 Game Boy Advance,而且盡量保留原始遊戲邏輯、資料與行為?

但如果你一路看下來,會發現它後來已經完全超出一般人對「移植」的想像。

它不是把 API 換掉、畫面縮小、聲音重接而已,而是把 Tyrian 2.1 的原始資料和 OpenTyrian 的 gameplay behavior 當成規格,然後在 GBA 極度有限的 CPU、VRAM、OAM、palette 與 timing budget 下,重新思考:

怎樣讓原本比 GBA 資源需求大很多的遊戲,仍然維持正確行為並穩定運行?

因此整個專案最後自然牽涉到:

演算法、資料結構、記憶體配置、快取策略、資源排程、即時系統、工作量裁剪、色彩量化,以及 ARM 等低階最佳化。

換句話說,表面上這是一個 GBA 遊戲移植,實際上更像是一個完整的 performance engineering 案例。


核心觀念:先不要做,再想怎麼做快

如果要先抓一個最值得其他專案借鏡的原則,我會選這句:

不要一開始就問「怎麼讓這段程式更快」,先問「這段工作到底需不需要做」。

這個專案的最佳化大致遵循這樣的順序:

消除不必要的工作
        ↓
縮小 working set
        ↓
選擇更適合的資料結構
        ↓
改善 locality / cache
        ↓
排程有限資源
        ↓
batch / loop fusion
        ↓
最佳化真正的 hot kernel
        ↓
最後才考慮 ARM / IWRAM

這個順序很重要。

因為在硬體很受限的平台上,真正的大幅提升通常不是:

把 100 次操作各快 10%。

而是:

直接讓其中 80 次根本不要發生。


1. Active Directory / Pool:不要每個 Tick 都掃全部物件

OpenTyrian 的 enemy pool,可以簡化理解成:

4 個 pool × 每個 25 slots
= 100 enemy slots

最直覺的程式會是:

每個 tick
for 0..99
    if active
        update

這在 PC 上可能根本不用在意。

但在 GBA 上,即使某個 slot 是空的,你仍然要付出:

所以 GBA 版本額外維護 compact active directory,例如 32-bit bitmask,用來直接知道哪些 enemy slot 是 active 或 collidable。

不過這裡有一個很重要的設計細節:

bitmask 只是「加速用索引」,不是 authoritative state。

也就是原始 enemy pool 還是遊戲真正狀態來源。

因此:

都不需要因為最佳化而改變。

這其實是一個非常實用的結構:

authoritative structure
+
auxiliary acceleration structure

你可以把它想成:

原本資料結構負責「正確」,額外 directory 負責「快」。


2. 不是所有 Pool 都該用 Bitmask

這裡有一個很值得注意的地方:作者沒有看到 bitmask 有效,就把所有 pool 全部改成 bit-scan。

Effect pool 大約有 200 slots。

它也曾經嘗試:

active mask
→ 只 bit-scan active effects

但如果 heavy scene 裡有 100 多個 effect 都 active,資料已經很 dense。

這時:

bit scan
+ index extraction
+ mask handling

反而可能比單純:

for 0..199
    if inactive continue

更慢。

所以最後的作法是:

這裡其實藏了一個很重要的演算法觀念:

Sparse representation 不是天然比較快,真正關鍵是資料密度。

也就是所謂的:

density crossover

Player shot 更直接採用 hybrid:

active count 小
→ bitmask traversal

active count 大
→ linear scan

這比「bitmask 一定高級」更成熟,因為真正的答案是:

哪一個方法在目前 density 下成本最低?


3. Cache Hint:用很便宜的預測,加速正確的 Cache

Projectile sprite cache 本身 hit rate 已經很高。

但即使 hit,仍可能需要掃描多個 cache entry 才找到對應 slot。

所以作者沒有把整個 cache 改掉,而是在前面放一層非常小的:

direct-mapped hint table

流程大概是:

key
 ↓
hash
 ↓
hint slot
 ↓
驗證完整 key
 ↓
吻合 → 直接使用

不吻合
 ↓
fallback 原本完整 cache lookup

這個設計最漂亮的地方是:

hint 不負責 correctness。

它不管理:

所以即使發生:

hash collision
stale hint

最壞也只是:

hint miss
→ fallback

不會產生錯誤 cache hit。

這個概念其實很值得拿去別的系統用:

cheap predictor + validation + authoritative fallback

也就是先猜,但一定驗證。

猜錯只會慢,不會錯。


4. Boss Sprite 問題最後不是排程,而是資料表示法

Boss 場景曾經出現零件消失。

第一個直覺很容易是:

GBA 只有 128 OAM,是不是 OAM 不夠?

但 telemetry 顯示 OAM 還沒滿。

真正爆掉的是 OBJ VRAM。

32×32 的 8bpp sprite 需要:

32 × 32 × 1 byte
= 1024 bytes

如果 Boss 同時使用大量不同 frame,cache 很快就被塞滿。

這時你再怎麼研究 LRU 或 replacement policy,都還是改變不了:

物理容量就是不夠。

所以解法不是「更聰明地淘汰」,而是直接改資料 representation:

32×32 8bpp
= 1024 bytes

       ↓

32×32 4bpp
= 512 bytes

同樣一塊 VRAM,可以放接近兩倍的 sprite。

這是非常典型的系統最佳化思路:

當 cache capacity 是根本問題時,先問資料本身能不能變小。

而不是只在 eviction policy 上打轉。


5. 4bpp 進一步引出 Palette Training

一旦轉成 4bpp,問題馬上又變成另一類:

一個 palette bank 只有 16 個 entries,而且通常 index 0 還要留給透明。

也就是實際只有大約:

15 個顏色可以使用

但 Tyrian 原本 artwork 是 256 色 indexed graphics。

所以你現在必須解決:

怎麼把原始 256 色中的資訊壓到 15 色,而且畫面不能爛掉?

這時專案就加入了 palette training / color quantization。

基本思想是把:

color frequency
+
distance from already-selected colors

一起考慮。

概念上類似:

score =
nearest color distance
×
frequency weight

這樣的好處是:

例如 Boss 可能是:

大量灰色金屬
+
少量紅色眼睛
+
少量藍色雷射

如果只看 histogram,很可能最後 15 色裡一堆都是相近灰色。

但加入 color distance 後,紅色與藍色這種「離現有 palette 很遠」的顏色,就比較容易被留下來。

這對 pixel art 特別合理。


6. Palette 訓練完後,再做 Lookup Table

訓練完 palette 還不夠。

如果每個 pixel 都要重新算:

這個原始顏色最接近哪一個 15 色 palette entry?

那 runtime 還是很貴。

所以它會先建立:

palette_map[256]
palette_error[256]

等於先把每個 source color 對應到:

真正轉 sprite 時就變成:

src color index
↓
table lookup
↓
4-bit palette index

這個方法很典型:

把 expensive calculation 移到 precompute,讓 hotpath 只查表。


7. Palette Pretraining:不要在 Boss 出場瞬間才訓練

後來又遇到另一個很實際的問題。

Boss 零件可能不是同一時間全部出現。

例如:

一開始只出現暗色零件
↓
先訓練出偏黑灰 palette

後面才出現:

綠色零件
紅色零件
亮色攻擊效果

此時原本 palette 已經不理想。

你當然可以 combat 中 retrain,但那又會把 CPU 壓力拉高。

最後比較漂亮的解法是:

讀取 level data
 ↓
知道 Boss cohort
 ↓
找到 Boss 相關 enemy definitions
 ↓
decode animation frames
 ↓
統計完整 histogram
 ↓
提前 train palette

也就是 Boss 還沒真正出場前,palette 已經準備好了。

這個觀念非常值得其他移植專案記住:

能預測的昂貴工作,就不要留在 latency-sensitive gameplay 階段。

能搬去:

就盡量搬。


8. OAM 不夠時,問題其實變成 Scheduler

GBA OAM 固定只有:

128 entries

但某些 Tyrian heavy scene 的 sprite demand 可以明顯超過 128。

這時你已經不能再問:

Renderer 能不能再快一點?

因為問題不是速度。

而是:

硬體就只有 128 個 slot。

所以這時應該把問題看成:

Resource Scheduling

系統必須決定:

誰一定要顯示?
誰可以延後?
誰優先?
誰可以犧牲?
誰要輪流取得資源?

可以概念化成:

高優先
player
boss
important enemies

中優先
projectiles

低優先
explosions
shadows
cosmetic effects

如果高優先物件沒把 OAM 用滿:

剩餘 OAM
→ 給 cosmetic

如果高優先本身已經爆量:

cosmetic
→ 可以被裁掉

這其實已經不是繪圖技巧,而是標準的有限資源排程問題。


9. Priority 不夠,還要 Fairness

如果永遠都固定順序分配:

前面的 effect 永遠顯示
後面的永遠消失

視覺結果會很糟。

所以系統還需要 round-robin 或 rotating cursor,讓低優先項目仍然有機會輪流被顯示。

這就形成:

priority
+
fairness
+
work-conserving allocation

這個觀念不只適用 GBA OAM。

如果你做的是:

其實都是同一類問題。

核心原則就是:

當需求可能超過硬體容量時,你需要 scheduler,不只是 allocator。


10. Candidate Snapshot:同一群資料不要反覆掃

OAM scheduler 還有一些很實際的最佳化。

原本可能:

掃一次算 demand
掃一次做 cache admission
掃一次 render

後來改成:

collect candidates once

每個 candidate 先算好:
screen position
visibility
OAM cost
draw stage
priority

後面的 scheduler、cache admission、render 都共享這份資料。

這就是:

materialize hot working set once


11. Loop Fusion:能一次走完,就不要走兩次

另一個同類型技巧是 loop fusion。

原本:

select enemy
↓
存 selection
↓
再掃一次 cache prime
↓
render

可以改成:

select
+
cache admission
+
cache acquire
+
record tile

同一趟 traversal 直接做完。

這種最佳化本身看起來沒有 ASM 那麼華麗,但實際上對 constrained hardware 非常有用。

因為你同時減少:


12. Adaptive Work Shedding:Simulation 跟 Presentation 要分開

這是整個移植裡我認為最重要的架構概念之一。

遊戲的:

logic
enemy movement
collision
event
RNG
boss progression
audio timing

屬於 authoritative simulation。

這些不能因為畫面忙不過來就亂 skip。

但:

sprite construction
OAM update
tile upload
VRAM presentation
background rendering

只是 presentation。

所以當系統真的 overloaded 時,不應該:

跳 gameplay tick

而應該:

gameplay tick 照跑

但某個 presentation frame 不建立

例如:

Simulation:
N
N+1
N+2
N+3

Presentation:
N
N+2
N+3

玩家少看到一個中間畫面,但:

這其實就是:

fixed timestep simulation + adaptive presentation cadence

這已經非常接近 real-time system 的設計思維。


13. Drop Frame 不代表「畫一半」

另一個很重要的細節是:

如果預估新 scene 來不及完整完成:

不要 commit 一半

而是:

保留上一張完整 scene

因為最糟的不是少一張畫面。

最糟的是產生:

一半新的 OAM
+
一半舊 VRAM
+
部分 cache update

這種 inconsistent state。

所以這個策略比較像:

完整 commit,或完全不 commit。

這跟 transaction / double buffering 的思考方式其實非常接近。


14. Collision:真正難的不是碰撞公式,而是 Pool 會 Mutation

Collision 表面上看很簡單:

for every player shot
    for every enemy
        collision test

但如果是:

81 shots × 100 enemies

candidate 數量很快就上去。

Active directory 可以先砍掉 inactive enemy。

但這裡真正困難的其實是:

collision 本身會改變 enemy pool。

例如:

shot hits enemy
 ↓
enemy dies
 ↓
spawn new enemy
 ↓
slot 被重新使用

所以不能簡單:

tick 開始時 snapshot active mask
↓
整個 collision 都用這份 snapshot

因為後面資料可能已經變了。

因此最佳化不能只考慮速度,還得維持:

這類問題其實比單純 SIMD 或 bitmask 更難。


15. Packed Collision:Miss Path 要快,Hit Path 要保守

後來 ARM collision kernel 有一個很漂亮的做法。

對同一個 active mask:

remaining_bits

保留在 register。

如果一路都 miss:

candidate A miss
candidate B miss
candidate C miss

就不要一直重新讀 authoritative mask。

但如果真的 hit:

hit
→ gameplay helper
→ 可能修改 enemy pool

那就把 cached mask 丟掉,重新 reload authoritative state。

所以它實際上是:

common miss path
→ aggressively cached

rare mutation path
→ conservative reload

這是一種非常漂亮的效能策略:

Optimistic fast path + correctness recovery point

常見路徑要快。

真正可能改狀態的地方再回到保守模式。


16. ARM Assembly 是最後一刀,不是第一刀

專案裡確實有 ARM / IWRAM 最佳化。

但更值得注意的是它並沒有把:

「改成 ARM」

視為第一反應。

像 starfield,一開始真正的問題不是語言。

原本流程可能反覆:

position / 320
position % 320

pixel 1 → tile lookup
pixel 2 → tile lookup
pixel 3 → tile lookup
...

後來先改成:

一次 divmod
一次 tile allocation
同一 tile 裡 batch 多個 pixels

這一步已經把演算法變合理。

最後才把真正留下的 hot kernel 改成 ARM。

所以真正的提升來源其實是:

algorithmic batching + eliminate repeated work

ARM 只是再往下榨。


17. 甚至有 ARM 比 C 慢的情況

這點其實很有教育價值。

不是所有:

都一定比較快。

有些很小的 helper 改成 ARM 後,可能因為:

反而輸給 C。

甚至有些看起來有 branch 的 C code,compiler 本來就能用 conditional execution 優化掉。

所以真正應該做的是:

看 machine code、看 profiling、看 benchmark。

而不是只看 source syntax。


整個移植到底用了哪些「技術牛肉」

如果你要讓其他工程師最快看懂,可以濃縮成這張表:

問題 採用技術
Pool 大量空 slot Active / Free Bit Directory
Sparse / Dense workload Density-dependent traversal
Cache hit 很多但 lookup 仍貴 Validated Direct-Mapped Hint
Sprite cache 容量不足 8bpp → 4bpp representation change
4bpp 色彩不足 Palette Training / Quantization
Gameplay 中 train 太貴 Boss cohort preload / pretraining
OAM 需求超過 128 Priority + Fairness Scheduler
重複掃相同 candidates Candidate Snapshot
多個 traversal 做類似工作 Loop Fusion
偶發 frame overload Deadline-aware frame dropping
長時間 overload Adaptive Work Shedding
不能跳 Gameplay tick Fixed-timestep Simulation
Collision pool 會 mutation Mutation-safe Active Directory
Hot path 重複地址計算 Batching
剩下真正 CPU bottleneck Selective ARM / IWRAM
最佳化可能破壞原邏輯 Differential / Regression Testing

對其他移植專案最值得帶走的思考方式

如果你是做其他平台移植,我覺得下面幾個問題比任何單一技巧都更值得記住。

不要先問:

怎麼讓這個 loop 更快?

先問:

為什麼這些物件需要被 visit?


不要先問:

Cache policy 還能怎麼調?

先問:

Data representation 能不能直接變小?


不要先問:

怎麼讓所有 sprite 都顯示?

先問:

當硬體容量不夠時,誰必須優先?


不要先問:

Gameplay 裡怎麼把這個昂貴操作做快?

先問:

能不能在 loading 或 preprocessing 就先算好?


不要先問:

能不能改 branchless?

先問:

Compiler 現在到底產生什麼 code?


不要先問:

這裡是不是該寫 ASM?

先問:

Profiling 證明它值得嗎?


最後其實可以濃縮成一句話

如果要用一句話概括 TyrianGbaPoc 的工程價值,我會寫:

先最佳化架構,再最佳化指令。

這個專案真正有意思的,不只是:

「Tyrian 居然可以跑在 GBA。」

而是它示範了,當你要把一個原本針對更寬裕平台設計的系統塞進資源極度有限的硬體時,可以如何把:

演算法、資料結構、Sparse Traversal、Cache、Palette Quantization、Memory Representation、Resource Scheduling、Real-Time Work Shedding、Profiling、Regression Testing 與 ARM Hotpath

全部組合起來。

所以更精確地說,它不是只有「遊戲移植」這件事。

它其實是一個很完整的案例:

如何透過系統化的效能工程,把一個較大型的既有軟體系統,縮進一個 CPU、記憶體、圖形資源與 timing budget 都非常有限的平台,同時盡量保留原始行為。

這也是為什麼這些技術不只對 GBA 有參考價值。

只要你面對的是:

這裡面大量思路都可以直接帶到其他專案裡。