可以,而且我會把它整理成一個更像「技術社群讀者一看就知道這個移植到底厲害在哪裡」的版本,而不是單純把功能列出來。
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 是空的,你仍然要付出:
- memory access
- branch
- loop overhead
- cache / ROM access cost
所以 GBA 版本額外維護 compact active directory,例如 32-bit bitmask,用來直接知道哪些 enemy slot 是 active 或 collidable。
不過這裡有一個很重要的設計細節:
bitmask 只是「加速用索引」,不是 authoritative state。
也就是原始 enemy pool 還是遊戲真正狀態來源。
因此:
- enemy slot order
- spawn slot selection
- RNG order
- gameplay semantics
都不需要因為最佳化而改變。
這其實是一個非常實用的結構:
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
更慢。
所以最後的作法是:
- 找空 slot → 用 free directory
- update/render → 保留 linear scan
這裡其實藏了一個很重要的演算法觀念:
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。
它不管理:
- eviction
- lifetime
- ownership
所以即使發生:
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
這樣的好處是:
- 常用顏色會優先保留
- 但少量、卻非常特殊的 accent color 也不容易被吃掉
例如 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 對應到:
- 哪個 4bpp palette index
- 誤差多少
真正轉 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 階段。
能搬去:
- loading
- blank screen
- preprocessing
- idle period
就盡量搬。
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。
如果你做的是:
- GPU resource
- DMA channel
- worker pool
- network queue
- descriptor heap
- embedded hardware unit
其實都是同一類問題。
核心原則就是:
當需求可能超過硬體容量時,你需要 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 非常有用。
因為你同時減少:
- loop overhead
- memory traversal
- repeated address calculation
- working-set reload
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
玩家少看到一個中間畫面,但:
- enemy movement 沒亂掉
- collision 沒少算
- RNG sequence 沒變
- gameplay speed 沒變
這其實就是:
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
因為後面資料可能已經變了。
因此最佳化不能只考慮速度,還得維持:
- 原始 slot traversal order
- penetrating shot semantics
- spawn/reuse 行為
- gameplay mutation correctness
這類問題其實比單純 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 慢的情況
這點其實很有教育價值。
不是所有:
- branchless
- bit-scan
- hand-written ASM
都一定比較快。
有些很小的 helper 改成 ARM 後,可能因為:
- call overhead
- ARM / Thumb transition
- compiler 原本 codegen 已經很好
- kernel 本身太小
反而輸給 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 有參考價值。
只要你面對的是:
- CPU 不夠
- 記憶體不夠
- GPU / VRAM 不夠
- 硬體資源數量固定
- 頻寬有限
- frame deadline 嚴格
- embedded / retro hardware port
這裡面大量思路都可以直接帶到其他專案裡。