跳至主要內容

開發紀錄

背景流線為什麼會掉幀:從固定 FPS 回到每次 rAF 繪製

從 Canvas 流線的實際 render loop 和瀏覽器量測,說明為什麼固定 30 FPS 會丟掉繪製機會,以及如何用每次 requestAnimationFrame 更新並保留動態速度。

林予晴
發布
  • Canvas
  • requestAnimationFrame
  • 動畫
  • 效能

背景動畫看起來只是頁面後面的裝飾,但它一旦固定在整個視窗上,就會一直和瀏覽器的繪製循環一起工作。這次流線掉幀的問題,不是 Canvas 不適合做動畫,而是 render loop 先把瀏覽器交給它的影格丟掉了。

先前的做法在 requestAnimationFrame callback 裡加上約 33 毫秒的間隔檢查。這等於把動畫最多限制在約 30 FPS,即使瀏覽器可以提供更多影格,也不會每次都繪製。現在的做法移除這個固定門檻,讓正常模式每次 rAF 都更新和繪製,再用經過時間控制移動幅度。主要判斷是:如果目標是讓動畫跟著實際顯示更新,就不要在 callback 裡硬丟掉固定比例的影格。

requestAnimationFrame callback 和真正繪製不是兩件可以隨便丟掉的事

瀏覽器會在下一次適合更新畫面的時機呼叫 requestAnimationFrame。callback 不保證永遠是固定 60 次,也不保證所有螢幕都以同一個頻率更新;瀏覽器可能因為工作負載、分頁狀態或裝置而改變節奏。

舊邏輯大致是:

收到 rAF callback
  ├─ 距離上次繪製不到約 33ms → 不畫,等下一次 callback
  └─ 否則才清空 Canvas、畫流線、更新粒子

這個判斷確實可以把繪製上限壓到約 30 FPS,但它同時丟掉了 callback 期間可以使用的畫面更新。當顯示器和瀏覽器正在以更高頻率更新時,動畫就可能感覺成一格一格跳動,而不是跟著顯示頻率自然移動。

問題不在於「30」這個數字本身永遠錯,而在於它把一個固定的輸出上限寫死在不一定固定的瀏覽環境裡。若真的需要限幀,應該先有明確的功耗、負載或視覺目標,再用量測支持取捨;不能只因為 callback 很多,就預設丟掉其中一部分是安全的。

現在的 render loop 做了什麼

目前正常動態模式的 render 每次被呼叫時會:

  1. 增加 callback 和 draw 的計數。
  2. 如果頁面不是暫停狀態,依上一次繪製到現在的時間計算 elapsed。
  3. 清空透明 Canvas。
  4. 繪製 HUD、流線和資料包粒子。
  5. 累加動畫時間與 FPS 計數。
  6. 再請求下一次 requestAnimationFrame。

程式中不再有「距離上一次繪製不到 33 毫秒就直接 return」的固定 FPS 門檻。正常模式因此把一次 callback 對應到一次 draw;頁面隱藏或使用者設定 reduced-motion 時,則走另外的狀態處理,而不是讓所有情況都無條件繪製。

這裡的 data-callbacks 和 data-draws 是為了讓檢查可以讀到實際計數,不是效能排名儀表板。它們回答的是「這次頁面執行了幾次 callback 和 draw」,不回答所有裝置的平均 FPS 或耗電量。

速度要跟時間走,不能只跟影格數走

移除固定 30 FPS 門檻後,如果粒子每次都只增加同一個固定值,動畫速度會跟 callback 數量綁在一起。callback 比較多時跑得快,callback 比較少時跑得慢。

目前流線的動畫時間使用:

const elapsedSeconds = Math.min(now - previousRenderTime, 50) / 1000
previousRenderTime = now
time += elapsedSeconds

粒子的進度也使用經過時間做縮放,並限制單次的最大縮放:

const frameScale = Math.min(elapsedSeconds * 30, 2)
p.progress += p.speed * frameScale

這不是把所有裝置變成完全相同的動畫,而是避免「每影格固定前進」造成刷新頻率差異,同時避免分頁恢復或主執行緒短暫卡住後,粒子一次跳得太遠。elapsedSeconds 的上限也代表它是一個視覺上的折衷,不是物理模擬。

只改掉 33 毫秒判斷、卻不處理時間差,會把一個問題換成另一個問題:畫面更新次數增加了,動畫速度卻可能跟著裝置而漂移。

我們實際量到的是什麼

目前腳本在正常動態模式會把 callback 和 draw 寫進背景容器的 dataset。用本機瀏覽器在頁面可見、未啟用 reduced-motion 的狀態觀察一秒,這次讀到:

callbacks 增量:60
draws 增量:60
頁面回報 FPS:約 60.1

這個結果支持一個很窄的判斷:在這次執行裡,正常模式沒有用固定 30 FPS 門檻丟掉一半 callback,callback 和 draw 的增量相同。

它不支持以下更大的結論:

  • 所有螢幕都會得到 60 FPS。
  • 所有手機都能順暢繪製 16 條流線和粒子拖尾。
  • Canvas 的 CPU、GPU 或電池成本已經足夠低。
  • 正式部署後的瀏覽器行為和本機完全相同。

動畫的實際感受仍然會受視窗大小、裝置刷新率、其他頁面工作和使用者設定影響。量測結果應該保留環境和觀察範圍,不把一次瀏覽器讀值寫成普遍效能保證。

回到每次 rAF,不代表每種狀態都一直跑

這次的修正只處理正常可見狀態的繪製頻率,不能把它理解成所有時候都要持續運算。背景腳本另外保留幾個邊界:

  • document.hidden 時取消下一次 rAF,回到頁面才重新排程。
  • prefers-reduced-motion: reduce 時停止動態循環,只重繪靜態流線。
  • resize 時調整 Canvas 和流線尺度,必要時重新繪製 reduced-motion 狀態。
  • pagehide 時清理監聽器、動畫和尚未取消的 frame。
  • BFCache 恢復時重新初始化背景控制,避免頁面回來後完全不動或重複綁定。

這些是生命週期和可及性邊界,與「正常模式是否每次 rAF 都畫」是不同問題。不能因為要修掉掉幀,就把隱藏分頁、reduced-motion 或離頁清理一起拿掉。

如果真的需要降頻,應該先說清楚原因

低頻更新不一定沒有合理用途。若量測顯示某個裝置在繪製背景時主內容受到影響,可以考慮降低線條數量、粒子數量、拖尾長度或在特定狀態停用效果。也可以針對明確的裝置條件或頁面狀態設計策略。

但「固定 30 FPS」只是其中一個方案,而且它會直接改變動畫更新機會。採用前至少要知道:

  • 要解決的是 CPU、GPU、電池還是主內容延遲?
  • 受影響的裝置或狀態是什麼?
  • 降頻後視覺是否真的改善,而不是只是把卡頓變成低頻?
  • reduced-motion、hidden tab 和離頁清理是否仍然成立?

沒有這些資料時,先讓 render loop 反映瀏覽器提供的影格,再量測實際成本,比先寫死一個看似保守的上限更容易知道問題在哪裡。

這次修正能證明什麼,不能證明什麼

程式差異和本機量測可以支持以下判斷:

  • 先前版本在 rAF callback 裡有約 33 毫秒的固定繪製間隔。
  • 目前正常模式移除了這個固定門檻,每次 render 都會執行 draw。
  • 動畫時間和粒子進度使用 elapsed time,而不是只靠固定影格增量。
  • 本次可見頁面觀察到 callback 和 draw 各增加 60 次左右,計數相同。

它們不能直接支持跨裝置效能、耗電、長時間穩定性或所有瀏覽器的結論。背景動畫是否值得保留,還要把主內容可讀性、使用者的 reduced-motion 設定和實際視覺驗收放在一起判斷。

修動畫時,先看繪製循環實際丟掉了什麼,再決定要改頻率、改工作量或改生命週期。固定數字看起來很容易理解,卻不一定是問題的真正邊界。

紀錄範圍

本文根據背景 Canvas 腳本、原先固定間隔的歷史實作、目前的 callback/draw 計數和一次本機瀏覽器觀察整理。量測只代表該次可見頁面執行,不宣稱所有裝置的效能,也不涵蓋完整的 hidden tab、reduced-motion 和長時間壓力測試。