同一艘船在 30 FPS 和 144 FPS 開起來不一樣:積分方法與固定步長
同一組推力、同一個彈簧參數,在 30 FPS 和 144 FPS 下跑出來的軌跡可以差很多。遊戲沒辦法真的算連續的運動,只能每幀取一次樣,用「這一幀的速度乘上這一幀的時間」去近似。即使乘上 deltaTime,更新的先後順序、阻尼寫法和時間累積方式仍會讓結果跟著幀率改變。
更正(2026-11-02 18:45):《Dark Souls II》的耐久度 bug 改為 Scholar of the First Sin 推出之後才由更新修正(修正建議:Claude、Codex(gpt-6-astra),兩者互相驗證);補上半隱式 Euler 能量大致守恆需要步長夠小的前提(修正建議:Codex(gpt-6-astra),Claude 驗證)。
〈朝向不等於位置:遊戲裡的向量、內積、叉積與座標系〉處理了船的空間運算,讓那艘船動起來之後,時間怎麼切也會影響結果。下面把參數固定,只換幀率,看看差異出在哪裡。
每一步用哪個速度
速度是位置對時間的變化率,加速度是速度對時間的變化率。物理書寫的是連續的關係,遊戲每幀只能做一次離散的更新:
v(t + dt) ≈ v(t) + a·dt
x(t + dt) ≈ x(t) + v·dt
問題在第二行的 v 要用哪一個。用更新前的速度叫顯式 Euler(explicit Euler),用剛更新完的速度叫半隱式 Euler(semi-implicit 或 symplectic Euler)。兩者都是一階方法,誤差跟 dt 成正比,差別在長時間跑下來的行為。Glenn Fiedler 的〈Integration Basics〉拿彈簧做比較:顯式 Euler 會持續增加能量,最後發散;半隱式 Euler 的能量平均上大致守恆,他也提到多數商用物理引擎用的是半隱式 Euler。Wikipedia 的條目同樣寫明它是 symplectic integrator,對不隨時間變化的系統幾乎守恆能量。這個性質有前提,步長要固定而且夠小:以下面 k=40 的彈簧為例,dt·√k 要小於 2(dt 約小於 0.316 秒),2 FPS 時半隱式 Euler 一樣會發散,本文的 30、60、144 FPS 都在這個範圍內。
實驗一:推力、彈簧與阻尼
推力、彈簧和阻尼這三個實驗,各自固定參數,只改幀率,其中阻尼會比較每幀乘 0.9 與依時間計算衰減的差異。這些範例都在 Python 3.13.16 上跑過:
import math
import sys
THRUST = 10.0 # m/s^2, constant acceleration for 2 seconds
def thrust_run(fps, method):
dt, x, v = 1.0 / fps, 0.0, 0.0
for _ in range(round(2.0 * fps)):
if method == "explicit": # position uses the old velocity
x += v * dt
v += THRUST * dt
else: # semi-implicit: velocity first
v += THRUST * dt
x += v * dt
return x
print("== 1. constant thrust for 2 s, exact x = 0.5*a*t^2 = 20.000 m ==")
for fps in (30, 60, 144):
print(f"{fps:3d} fps explicit {thrust_run(fps, 'explicit'):7.3f} m"
f" semi-implicit {thrust_run(fps, 'semi'):7.3f} m")
def spring_run(fps, method, seconds=10.0, k=40.0):
"""Camera arm: undamped spring pulling x back to 0. Energy should stay 0.5*k*1^2."""
dt, x, v = 1.0 / fps, 1.0, 0.0
for _ in range(round(seconds * fps)):
a = -k * x
if method == "explicit":
x, v = x + v * dt, v + a * dt
else:
v += a * dt
x += v * dt
return 0.5 * v * v + 0.5 * k * x * x
print("\n== 2. undamped spring, k=40, 10 s, starting energy 20.000 ==")
for fps in (30, 60, 144):
print(f"{fps:3d} fps explicit energy {spring_run(fps, 'explicit'):12.3f}"
f" semi-implicit {spring_run(fps, 'semi'):8.3f}")
def damp_run(fps, rule, seconds=1.0):
dt, v = 1.0 / fps, 100.0
for _ in range(round(seconds * fps)):
if rule == "per-frame":
v *= 0.9 # "lose 10% each frame"
else:
v *= math.exp(-6.0 * dt) # rate written per second
return v
print("\n== 3. drag: speed left after 1 s, starting at 100 ==")
for fps in (30, 60, 144):
print(f"{fps:3d} fps v*=0.9 per frame {damp_run(fps, 'per-frame'):10.6f}"
f" v*=exp(-6*dt) {damp_run(fps, 'exp'):8.4f}")
== 1. constant thrust for 2 s, exact x = 0.5*a*t^2 = 20.000 m ==
30 fps explicit 19.667 m semi-implicit 20.333 m
60 fps explicit 19.833 m semi-implicit 20.167 m
144 fps explicit 19.931 m semi-implicit 20.069 m
== 2. undamped spring, k=40, 10 s, starting energy 20.000 ==
30 fps explicit energy 9260474.577 semi-implicit 18.263
60 fps explicit energy 15148.154 semi-implicit 19.203
144 fps explicit energy 320.805 semi-implicit 19.677
== 3. drag: speed left after 1 s, starting at 100 ==
30 fps v*=0.9 per frame 4.239116 v*=exp(-6*dt) 0.2479
60 fps v*=0.9 per frame 0.179701 v*=exp(-6*dt) 0.2479
144 fps v*=0.9 per frame 0.000026 v*=exp(-6*dt) 0.2479
等加速度的情況下,兩種方法都有誤差,一個少算、一個多算,幀率越高越接近 20 m,光看這組結果還分不出誰比較好。
鏡頭跟隨常用彈簧把鏡頭拉回目標,拿掉阻尼、只看能量,兩種方法的差異就很明顯。顯式 Euler 在 30 FPS 跑 10 秒,能量從 20 漲到九百多萬,鏡頭早就飛出畫面;144 FPS 也漲到 16 倍。半隱式 Euler 三種幀率都在 18 到 20 之間,幀率越高越接近 20。位置和速度的更新建議用半隱式 Euler,低幀率下鏡頭抖動或過衝時,也可以先檢查積分方法。
阻尼的寫法最常出錯。「每幀速度乘 0.9」看起來很合理,但它把衰減綁在幀數上:一秒後 30 FPS 還剩 4.2,144 FPS 只剩 0.000026。改寫成 v *= exp(-k·dt),衰減就只跟經過的時間有關,三種幀率的結果相同。這條式子是 dv/dt = -k·v 的精確解,所以對任何 dt 都成立。
固定步長與累積器
既然結果會隨 dt 改變,最直接的做法是讓物理永遠用同一個 dt。Fiedler 的〈Fix Your Timestep!〉把它寫成累積器:畫面每幀產生一段真實時間,丟進累積器;物理每次拿走固定的一小段來算,不夠一段就留到下一幀。剩下的零頭除以步長得到 alpha,畫面用它在上一個和這一個物理狀態之間插值,低幀率時看起來不會一頓一頓。
他也提到一個陷阱:如果一次物理更新花的真實時間比它模擬的時間還長,累積器會越積越多,每幀要補算更多步,結果更慢,他稱為 spiral of death。實作上會限制每幀最多補幾步。
下面接在同一個檔案後面,讓物理固定在 120 Hz,畫面分別跑 30、60、144 FPS:
def fixed_step_run(frame_times, step=1.0 / 120):
"""Accumulator: the renderer produces time, physics consumes it in fixed steps."""
acc, x, v, steps = step * 0, 0.0, 0.0, 0 # keep acc in the same type as step
for ft in frame_times:
acc += ft
while acc >= step:
v += THRUST * step
x += v * step
acc -= step
steps += 1
alpha = acc / step # blend factor for rendering
return x, steps, alpha
print("\n== 4. fixed 120 Hz physics under different render rates (2 s of frames) ==")
for fps in (30, 60, 144):
x, steps, alpha = fixed_step_run([1.0 / fps] * round(2.0 * fps))
print(f"render {fps:3d} fps -> physics steps {steps}, x = {x:.6f} m, alpha {alpha:.3f}")
from fractions import Fraction
print("same loop, time kept as exact fractions instead of float seconds:")
for fps in (30, 60, 144):
frames = [Fraction(1, fps)] * (2 * fps)
x, steps, alpha = fixed_step_run(frames, step=Fraction(1, 120))
print(f"render {fps:3d} fps -> physics steps {steps}, x = {float(x):.6f} m, alpha {float(alpha):.3f}")
== 4. fixed 120 Hz physics under different render rates (2 s of frames) ==
render 30 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 60 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 144 fps -> physics steps 239, x = 19.916667 m, alpha 1.000
same loop, time kept as exact fractions instead of float seconds:
render 30 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 60 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 144 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
30 和 60 FPS 都跑了 240 步,物理跟畫面幀率脫鉤,結果一模一樣。144 FPS 卻少了一步,alpha 顯示 1.000,累積器其實還差一點點才滿一步。1/30、1/60、1/144 在二進位裡都無法精確表示,30 和 60 剛好沒跨過邊界,144 則因為浮點誤差,差了一點沒滿一步。
換成精確的分數,三種幀率都回到 240 步。實務上不會用 Fraction,累積器通常用整數計時,例如奈秒或固定的 tick,作業系統的時鐘本來就是整數。每一步的 dt 固定之後,仍要留意時間怎麼累加,用浮點秒數慢慢加,步數就可能不同。
高速物體穿牆
離散取樣的另一個代價是會漏掉中間發生的事。一顆 600 m/s 的子彈飛向 1 公尺厚的牆,如果只檢查每一幀的位置有沒有落在牆裡(同樣接在後面):
def tunnel(fps, speed=600.0, wall_at=50.0, thickness=1.0, swept=False):
"""A bullet flies along +x. Does any sample land inside the wall?"""
dt, x = 1.0 / fps, 0.0
while x < 200:
nx = x + speed * dt
if swept:
if x <= wall_at + thickness and nx >= wall_at:
return "hit"
elif wall_at <= nx <= wall_at + thickness:
return "hit"
x = nx
return "missed"
print("\n== 5. 600 m/s bullet vs 1 m wall at x=50 ==")
for fps in (30, 60, 144, 600):
print(f"{fps:3d} fps step {600/fps:6.2f} m point test: {tunnel(fps):6}"
f" swept test: {tunnel(fps, swept=True)}")
print(f"\nPython {sys.version.split()[0]}")
== 5. 600 m/s bullet vs 1 m wall at x=50 ==
30 fps step 20.00 m point test: missed swept test: hit
60 fps step 10.00 m point test: hit swept test: hit
144 fps step 4.17 m point test: missed swept test: hit
600 fps step 1.00 m point test: hit swept test: hit
Python 3.13.16
144 FPS 比 60 FPS 更新得更頻繁,卻穿過去了:每步 4.1667 m,第 12 步理論上剛好落在 50,浮點誤差讓它停在 49.999…,差一點沒進牆,下一步就跳到牆後。能不能打中取決於取樣點剛好落在哪裡,跟幀率高低沒有單調關係。改成檢查「這一步走過的線段」有沒有碰到牆(swept test,引擎裡常叫 continuous collision detection),四種幀率都打中,所以處理高速物體時,還是得用掃掠檢查或射線。
幀率影響了跳躍與耐久度
Quake III 的跳躍高度就有這類紀錄。OpenArena 的維基說明,引擎從 Quake III 繼承的是跟幀率相關的物理,PMove() 在每次 client 畫一幀時呼叫;標準重力 800 下,125 FPS 能跳得比 90 FPS 高一點,把 pmove_fixed 打開才改用固定的移動步長。
《Dark Souls II》PC 版能跑 60 FPS,武器耐久卻掉得比 30 FPS 快。KitGuru 2015 年 5 月 9 日的報導說,這個問題拖了一年多,到 Scholar of the First Sin 版本推出之後,才由一次更新修正。報導沒有說明技術原因,這裡也不推測。
跨平台的確定性要另外處理
固定步長讓同一台機器上的結果不受畫面幀率影響,但跨平台、跨編譯器要算出逐位元相同的結果是另一件事。浮點運算的結果會受到指令選擇、運算順序、是否使用融合乘加(FMA)、數學函式庫實作影響。多人連線遊戲如果採用只同步輸入、各自模擬的 lockstep 架構,常見做法是定點數,或嚴格控制編譯選項和執行環境,這部分不在這篇範圍內,具體要求要看引擎與平台文件(需驗證)。
補充筆記
- 乘上
deltaTime之後,結果仍會受更新順序、阻尼寫法與時間累積方式影響。半隱式 Euler 先更新速度,再用新速度更新位置,步長夠小時,彈簧系統的能量大致守恆,顯式 Euler 則會讓能量持續增加。 - 阻尼用
exp(-k·dt)表示,衰減才跟經過的時間有關,每幀乘固定比例會讓不同幀率得到不同結果。 - 固定步長加累積器可以讓物理跟畫面脫鉤,剩下的零頭用來插值。累積器用整數計時,避免浮點秒數相加造成多一步或少一步;跨平台要算出相同結果,仍得另外處理浮點運算的細節。
- 高速物體仍要做掃掠檢查或用射線,提高幀率不保證打得到。
延伸閱讀
- Glenn Fiedler〈Fix Your Timestep!〉:累積器、插值與 spiral of death,遊戲物理領域常被引用的技術文章。
- Glenn Fiedler〈Integration Basics〉:顯式 Euler、半隱式 Euler 與 RK4 在彈簧上的能量比較,同一系列技術文章。
- Wikipedia〈Semi-implicit Euler method〉:更新式與 symplectic 性質,百科條目,用來對照公式。
- OpenArena Wiki〈Game physics〉:Quake III 系引擎跟幀率相關的物理與
pmove_fixed,社群維護的文件。 - KitGuru〈That Dark Souls 2 weapon durability bug has finally been fixed〉:2015-05-09,新聞報導,只記錄現象與修正時間。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16 實測。