SpriteKit 效能調校:三個讓遊戲不再掉幀的改動
ConjuGate 在只有玩家和敵人的時候跑得很順。加了砲塔、無人機、散射之後,畫面開始掉幀,手機開始發燙。
我在三個地方找到了瓶頸,修完之後掉幀消失,耗電量也降了下來。這篇把三個問題和對應的解法寫下來。
問題一:每秒 68 次 alloc
ConjuGate 的武器系統有普通子彈、散射、雷射、無人機、砲塔。砲塔加無人機全開的時候,一秒大約發射 68 顆子彈。每顆子彈都是 BulletNode(style:) 新建一個 SKSpriteNode instance。
一秒 68 次 alloc + init,Swift 的 ARC 要追蹤 68 個新物件的 reference count,SpriteKit 要把 68 個新 node 加進 scene graph。子彈飛出畫面之後被 removeFromParent() 移除,ARC 把它 dealloc。然後下一幀又 alloc 68 個新的。
GC 壓力直接反映在畫面上:每隔幾秒會有一次明顯的卡頓,大約 2-3 幀的時間。
解法:Object Pool
子彈飛出畫面之後不要 dealloc,放進一個陣列裡。下次要發射的時候,先看陣列裡有沒有回收的子彈,有的話拿出來重新設定屬性,沒有的話才 alloc 新的。
private var bulletPool: [BulletNode] = []
private func obtainBullet(style: BulletStyle) -> BulletNode {
if let bullet = bulletPool.popLast() {
bullet.configure(as: style)
return bullet
}
return BulletNode(style: style)
}
private func recycleBullet(_ bullet: BulletNode) {
bullet.removeFromParent()
bulletPool.append(bullet)
}
遊戲跑幾秒之後,pool 裡累積了大約 30-40 顆回收的子彈,之後不再有新的 alloc。68 次/秒的 alloc+dealloc 變成 0 次/秒。卡頓消失。
改動很小:把所有 BulletNode(style:) 換成 obtainBullet(style:),把所有 bullet.removeFromParent() 換成 recycleBullet(bullet)。邏輯不變,只是物件的生命週期從「用完即丟」變成「回收再用」。跟餐廳洗盤子一樣。
問題二:HUD 每幀重繪
SpriteKit 的 update() 每幀呼叫一次。在 60 FPS 下,每秒呼叫 60 次。我在 update 裡面更新 HUD:分數、倍率、時間、血量、特殊技能條。
問題是,大部分的幀裡這些值根本沒變。分數可能 5 秒才變一次,血量可能整場遊戲才變兩次。但每一幀我都在重新設定 hudScore.text、重新建立 SKColor、重新觸發 glyph recalculation。
60 FPS × 5 個 HUD 元素 = 每秒 300 次無意義的 text 設定和 SKColor allocation。
解法:Dirty Flag
每個 HUD 元素記住上一次的值,只有值變了才更新。
private var lastHudScore: Int = -1
private var lastHudMult: Double = -1
private var lastHudTime: Int = -1
private var lastHudHearts: Int = -1
// 在 update() 裡面
if run.score != lastHudScore {
lastHudScore = run.score
hudScore.text = String(format: "%05d", run.score)
}
let timeInt = Int(ceil(run.timeLeft))
if timeInt != lastHudTime {
lastHudTime = timeInt
hudTime.text = "\(timeInt)s"
hudTime.fontColor = run.timeLeft < 10
? SKColor(red: 0.886, green: 0.282, blue: 0.239, alpha: 1)
: SKColor(red: 0.949, green: 0.718, blue: 0.251, alpha: 1)
}
特殊技能條也一樣,xScale 和 color 只有在值變了的時候才設定:
let fillScale = specialActive > 0 ? 1.0 : specialMeter
if hudSpecialFill?.xScale != fillScale {
hudSpecialFill?.xScale = fillScale
}
分數一秒變一次的話,HUD 更新從每秒 300 次降到每秒 5 次左右。省下的不只是 CPU,還有 SKColor allocation 和 glyph recalculation 的記憶體壓力。
問題三:60 FPS 在手機遊戲裡太奢侈
ConjuGate 是一個垂直跑酷 + 答題的遊戲,畫面上的動作沒有格鬥遊戲那麼快。60 FPS 和 40 FPS 在這種遊戲節奏下,玩家感受不到差異。但 CPU 和 GPU 的工作量差了 33%,直接反映在電池消耗上。
解法:降到 40 FPS + real delta time
SwiftUI 的 SpriteView 沒有暴露 preferredFramesPerSecond 的設定。我用 UIViewRepresentable 包了一個自訂的 SKView:
struct GameSKView: UIViewRepresentable {
let scene: GameScene
func makeUIView(context: Context) -> SKView {
let view = SKView()
view.preferredFramesPerSecond = 40
view.ignoresSiblingOrder = true
view.presentScene(scene)
return view
}
func updateUIView(_ uiView: SKView, context: Context) {}
}
降 FPS 之後有一個關鍵的改動:delta time 不能再寫死 1.0 / 60.0。原本的 update loop 假設每幀間隔固定 16.67ms,降到 40 FPS 之後實際間隔是 25ms,所有速度都會變慢 33%。
改成用 real delta time:
private var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
let dt: TimeInterval
if lastUpdateTime == 0 {
dt = 1.0 / 40.0
} else {
dt = min(currentTime - lastUpdateTime, 0.1)
}
lastUpdateTime = currentTime
// 所有移動、計時都用 dt
}
min(..., 0.1) 是一個 cap:如果某一幀因為系統排程延遲了 500ms,dt 不會超過 0.1 秒,避免物件瞬間跳過半個畫面。
額外的小優化
除了三個主要問題,還有幾個小改動也有幫助:
- 子彈出畫面的判定邊距從 20px 縮到 5px。原本子彈飛出畫面 20px 才移除,代表畫面外同時有更多看不見的子彈在跑 update。縮到 5px,同時存在的子彈數量減少了大約 15%。
- 浮動文字的淡出速度加快 1.5 倍,alpha 低於 0.05 就移除。半透明的 node 會觸發 blending,alpha 很低但還沒到 0 的 node 是浪費 GPU 的 overdraw。
Conjugator 的 static cache
ConjuGate 的 Conjugator 負責把一個日文單字變換成各種活用形。QuizScheduler 在 rebuildPool 的時候會對每個單字呼叫一次 Conjugator.conjugate(word),出題的時候又呼叫一次。同一個單字,同樣的計算做了兩遍。
80 個單字 × 2 次 = 160 次重複計算。加了一個 static dictionary 做 cache 之後,第二次呼叫直接從 cache 拿,省掉了一半的 conjugation 計算。
效果
所有改動加起來:
- 砲塔 + 無人機全開的場景不再掉幀
- 手機發燙程度明顯降低(從 60 FPS 降到 40 FPS 省了 33% 的 CPU/GPU 工作量)
三個問題裡面,object pool 的效果最明顯,因為它直接消除了每秒 68 次的 alloc/dealloc cycle。dirty flag 和降 FPS 的效果比較不容易用肉眼看到,但在電池消耗和長時間遊玩的穩定性上有差。
如果你的 SpriteKit 遊戲也有掉幀問題,先用 Instruments 的 Allocations 看每秒有多少次 alloc。大部分情況下,per-frame allocation 是最大的瓶頸。
ConjuGate 是一個用 SpriteKit 寫的 iOS 遊戲,在打怪的過程中學日文動詞變化。這篇是「費曼的實習生」系列,記錄遊戲開發中的效能調校經驗。