# 同一艘船在 30 FPS 和 144 FPS 開起來不一樣：積分方法與固定步長

> 遊戲把連續的運動切成一幀一幀計算，更新順序、阻尼寫法和時間步長都會讓結果跟著幀率改變。比較顯式與半隱式 Euler、固定步長累積器、浮點誤差與穿牆，附 Python 實跑數字。

原文：https://sheng.page/posts/frame-rate-and-game-physics-integration/ · 發布：2026-10-09 · 更新：2026-11-02 · 作者：Sheng

同一組推力、同一個彈簧參數，在 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 驗證）。

〈[朝向不等於位置：遊戲裡的向量、內積、叉積與座標系](/posts/game-math-vectors-and-coordinate-spaces/)〉處理了船的空間運算，讓那艘船動起來之後，時間怎麼切也會影響結果。下面把參數固定，只換幀率，看看差異出在哪裡。

## 每一步用哪個速度

速度是位置對時間的變化率，加速度是速度對時間的變化率。物理書寫的是連續的關係，遊戲每幀只能做一次離散的更新：

```text
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 上跑過：

```python
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}")
```

```text
== 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：

```python
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}")
```

```text
== 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 公尺厚的牆，如果只檢查每一幀的位置有沒有落在牆裡（同樣接在後面）：

```python
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]}")
```

```text
== 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!](https://gafferongames.com/post/fix_your_timestep/)〉：累積器、插值與 spiral of death，遊戲物理領域常被引用的技術文章。
- Glenn Fiedler〈[Integration Basics](https://gafferongames.com/post/integration_basics/)〉：顯式 Euler、半隱式 Euler 與 RK4 在彈簧上的能量比較，同一系列技術文章。
- Wikipedia〈[Semi-implicit Euler method](https://en.wikipedia.org/wiki/Semi-implicit_Euler_method)〉：更新式與 symplectic 性質，百科條目，用來對照公式。
- OpenArena Wiki〈[Game physics](https://openarena.fandom.com/wiki/Game_physics)〉：Quake III 系引擎跟幀率相關的物理與 `pmove_fixed`，社群維護的文件。
- KitGuru〈[That Dark Souls 2 weapon durability bug has finally been fixed](https://www.kitguru.net/gaming/matthew-wilson/that-dark-souls-2-weapon-durability-bug-has-finally-been-fixed/)〉：2015-05-09，新聞報導，只記錄現象與修正時間。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 範例在 Python 3.13.16 實測。