Elytra
Blog数学

为什么游戏里的“积分”不是高中数学那个积分?

从高中微积分,到 Euler、半隐式 Euler、Verlet 与 RK4:理解游戏物理如何把连续世界离散成一帧一帧的计算。

如果你第一次在游戏开发中看到“积分器(Integrator)”这个词,可能会有一点困惑。

我们在高中或大学微积分里学到的积分,通常长这样:

∫abf(x) dx\int_a^b f(x)\,dx

然后开始求原函数、换元、分部积分。

可到了游戏引擎里,所谓“积分”却常常只是几行代码:

velocity += acceleration * dt;
position += velocity * dt;

没有积分号,没有原函数,甚至连微积分都看不见。

但奇怪的是:

这几行代码,确实是在做积分。

更神奇的是,如果把这两行代码交换一下:

position += velocity * dt;
velocity += acceleration * dt;

数学意义看起来差不多,实际运行出来的物理世界却可能完全不同。

一个弹簧可能稳定振动,另一个却越振越大,最后直接“爆炸”。

为什么?

这背后其实连接着一整条非常有意思的知识链:

微积分 → 微分方程 → 数值积分 → 游戏物理 → 数值稳定性

理解这件事,也就理解了计算机究竟是如何模拟一个连续世界的。

核心问题

游戏里的“积分”并不是另一种数学积分。

它仍然是积分,只不过现实中的运动通常由连续微分方程描述,而计算机只能在离散时间点上进行有限次计算。

因此游戏开发真正面对的问题是:

怎样用一帧一帧的离散计算,近似连续的积分过程?

1从高中数学的积分开始

假设一个物体沿直线运动。

它的位置记作:

x(t)x(t)

速度就是位置随时间的变化率:

v(t)=dxdtv(t)=\frac{dx}{dt}

加速度则是速度随时间的变化率:

a(t)=dvdta(t)=\frac{dv}{dt}

因此反过来:

v(t)=v(0)+∫0ta(τ) dτv(t)=v(0)+\int_0^t a(\tau)\,d\tau

而位置则是:

x(t)=x(0)+∫0tv(τ) dτx(t)=x(0)+\int_0^t v(\tau)\,d\tau

这就是我们熟悉的积分。

如果加速度永远不变:

a(t)=aa(t)=a

那么可以直接得到解析解:

v(t)=v0+atv(t)=v_0+at

以及:

x(t)=x0+v0t+12at2x(t)=x_0+v_0t+\frac12at^2

这时候事情非常简单。

问题在于:

真实游戏里的力几乎从来没有这么简单。

比如弹簧:

F=−kxF=-kx

再考虑阻尼:

F=−kx−cvF=-kx-cv

根据牛顿第二定律:

F=maF=ma

于是:

md2xdt2=−kx−cdxdtm\frac{d^2x}{dt^2} = -kx-c\frac{dx}{dt}

现在得到的就不是一个简单的“求面积问题”了,而是一个:

微分方程。

游戏物理真正需要解决的,正是大量这样的微分方程。

2现实是连续的,游戏却是一帧一帧的

理论上的时间是连续的:

0 ───────────────────────────────→ t

但计算机不可能计算:

t=0.000000000001t=0.000000000001

然后再计算:

t=0.000000000002t=0.000000000002

一直计算到永远。

游戏只能选择一些离散的时间点:

0    Δt    2Δt    3Δt    4Δt    ...
│────│─────│──────│──────│────────→ t

例如物理系统每秒更新 50 次:

Δt=0.02s\Delta t=0.02s

或者每秒更新 60 次:

Δt≈0.01667s\Delta t\approx0.01667s

于是原本连续的问题:

dvdt=a\frac{dv}{dt}=a

只能近似成:

ΔvΔt≈a\frac{\Delta v}{\Delta t}\approx a

整理:

Δv≈aΔt\Delta v\approx a\Delta t

于是:

vn+1=vn+anΔtv_{n+1}=v_n+a_n\Delta t

代码就是:

velocity += acceleration * dt;

同理:

dxdt=v\frac{dx}{dt}=v

可以写成:

xn+1=xn+vnΔtx_{n+1}=x_n+v_n\Delta t

代码就是:

position += velocity * dt;

看到这里,游戏里的“积分”就已经出现了。

它并没有真的去计算一个漂亮的原函数。

它做的是:

沿着时间轴向前迈一小步,然后再迈一小步,再迈一小步。

只要这些步足够小,我们就希望它最终能够逼近真实的连续运动。

Loading diagram…

这类方法因此被称为:

Numerical Integration,数值积分。

3最简单的方法:Explicit Euler

最直观的数值积分方法叫做:

显式 Euler 法(Explicit Euler)。

假设当前状态是:

xn,vnx_n,\quad v_n

我们使用当前速度更新位置:

xn+1=xn+vnΔtx_{n+1} = x_n+v_n\Delta t

然后使用当前加速度更新速度:

vn+1=vn+anΔtv_{n+1} = v_n+a_n\Delta t

代码就是:

position += velocity * dt;
velocity += acceleration * dt;

思路非常自然:

我知道“现在”的运动趋势,所以沿着当前趋势往前走一步。

这其实就是把曲线局部看成一条直线。

假设有:

dydt=f(t,y)\frac{dy}{dt}=f(t,y)

Euler 法就是:

yn+1=yn+f(tn,yn)Δty_{n+1} = y_n+ f(t_n,y_n)\Delta t

它看了一眼当前斜率,然后说:

下一小段时间,大概就照这个方向走吧。

这就是 Euler 法最核心的思想。

4问题来了:Euler 可能偷偷给系统“充能”

考虑一个最经典的系统:

没有阻尼的理想弹簧。

弹簧力:

F=−kxF=-kx

于是:

a=−kmxa=-\frac{k}{m}x

理想情况下,一个弹簧应该永远振荡:

←────●────→

它不断在两种能量之间转换。

弹性势能:

Ep=12kx2E_p=\frac12kx^2

动能:

Ek=12mv2E_k=\frac12mv^2

总能量:

E=12kx2+12mv2E= \frac12kx^2+ \frac12mv^2

如果没有阻尼和外力,理论上:

E=constantE=\text{constant}

也就是说:

能量应该守恒。

可是如果用显式 Euler:

position += velocity * dt;
velocity += acceleration * dt;

经常会出现一种诡异现象:

小振荡
   ↓
更大的振荡
   ↓
更大的振荡
   ↓
越来越大
   ↓
💥

没人给这个弹簧输入能量。

但数值算法自己创造出了能量。

这就是游戏物理中非常重要的概念:

5数值误差并不只是“位置差了一点”

初学数值计算时,我们很容易认为:

误差不就是算出来的位置差几毫米吗?

并不一定。

在动态系统中,一个很小的误差可能会影响下一步计算,而下一步又继续影响后面的计算。

于是:

Loading diagram…

尤其对于:

  • 弹簧
  • 摆
  • 轨道运动
  • 刚体约束
  • 关节
  • 布料

这些会不断交换能量的系统,数值积分器对“能量”的处理方式会直接决定整个模拟是否稳定。

于是就出现了一个非常有意思的方法。

6只交换两行代码:Semi-Implicit Euler

把之前的代码:

position += velocity * dt;
velocity += acceleration * dt;

交换一下:

velocity += acceleration * dt;
position += velocity * dt;

现在得到的就是:

Semi-Implicit Euler

也叫:

Symplectic Euler

中文通常称为:

半隐式 Euler / 辛 Euler。

对应公式:

vn+1=vn+anΔtv_{n+1} = v_n+a_n\Delta t

然后:

xn+1=xn+vn+1Δtx_{n+1} = x_n+v_{n+1}\Delta t

注意这里最重要的区别。

显式 Euler 使用:

vnv_n

更新位置。

而半隐式 Euler 使用刚刚得到的新速度:

vn+1v_{n+1}

更新位置。

就是这么小的区别。

但对于经典力学中的许多系统,它的长期行为可能好得多。

7为什么半隐式 Euler 更稳定?

这里真正有趣的地方不是:

半隐式 Euler “误差更小”。

事实上,如果只看方法的阶数,显式 Euler 和半隐式 Euler 都属于一阶方法:

O(Δt)O(\Delta t)

也就是说,把时间步长减半,它们的全局误差大致都会按同一个数量级下降。

真正的区别在于:

它们如何改变系统的几何结构与能量。

对于理想振荡系统,显式 Euler 往往倾向于让能量不断增加。

轨迹可能像这样:

phase space

velocity
   ↑
   │         ╭────────╮
   │      ╭──╯        ╰──╮
   │    ╭─╯              ╰─╮
───┼────────────────────────────→ position
   │

理论上的相空间轨迹应该是一条闭合曲线。

但显式 Euler 可能让轨迹不断向外扩张:

横轴为位置 x,纵轴为速度 v;虚线圆是理想守恒轨迹。

意味着系统凭空获得越来越多的能量。

而半隐式 Euler 在许多经典机械系统中具有更好的长期能量行为。

它的能量往往会在真实值附近上下波动,而不是无限制地漂移。

一个非常重要的区别

“稳定”并不等于“精确”。

一个积分器可能每一步都有误差,但长期运行依然保持合理的物理结构。

另一个积分器可能短时间看起来很精确,却逐渐把系统能量推向完全错误的方向。

实时物理尤其在意这种长期稳定性。

这也是为什么游戏物理中不能只问:

哪个方法单步误差最小?

还必须问:

跑 10 秒、100 秒以后,它还像一个物理世界吗?

8为什么叫 Symplectic Euler?

这里会进入一点更深的数学。

经典力学不仅仅描述:

xx

它通常同时描述位置和动量:

(q,p)(q,p)

这些变量构成所谓的:

Phase Space,相空间。

经典 Hamilton 力学具有一种特殊的几何结构,叫:

Symplectic Structure,辛结构。

辛积分器的重要特点,是在离散模拟中尽量保留这种结构。

它不意味着:

En+1=EnE_{n+1}=E_n

严格成立。

但对于许多 Hamilton 系统,它往往能够避免长期系统性地增加或减少能量。

于是你会看到一种很有意思的现象:

真实系统:

Energy
│ ─────────────────────────
│
└─────────────────────────→ time

显式 Euler:

Energy
│                     /
│                 ___/
│             ___/
│         ___/
│________/
└─────────────────────────→ time

Symplectic Euler:

Energy
│   ~~~~ ~~~~ ~~~~ ~~~~
│ ~~    ~    ~    ~
│────────────────────────
└─────────────────────────→ time

它不完美。

但它通常不会一路逃向无穷大。

对于游戏而言,这往往比单纯追求局部精度更重要。

9那为什么不直接用“完美公式”?

你可能会问:

如果我们知道:

x(t)=x0+v0t+12at2x(t)=x_0+v_0t+\frac12at^2

为什么不直接计算?

因为这个公式要求:

a=constanta=\text{constant}

但真实游戏中的加速度往往取决于当前状态。

例如弹簧:

a=−kmxa=-\frac{k}{m}x

空气阻力:

Fd=−cvF_d=-cv

或者:

Fd∝−∣v∣vF_d\propto -|v|v

万有引力:

F=Gm1m2r2F= G\frac{m_1m_2}{r^2}

角色可能发生碰撞。

轮胎可能突然接触地面。

一个 Joint 可能突然达到角度限制。

玩家甚至可能在这一帧按下按钮产生新的力。

于是:

a=a(x,v,t,⋯ )a=a(x,v,t,\cdots)

也就是说:

加速度本身也在不断变化。

这时候通常不存在一个可以直接塞进代码的漂亮解析公式。

数值积分就是为了处理这种情况。

10Verlet:另一种游戏开发经典积分器

游戏开发里另一个非常著名的方法是:

Verlet Integration。

它尤其常见于:

  • 布料
  • 绳子
  • 粒子
  • PBD
  • 约束系统

一种常见的位置形式是:

xn+1=2xn−xn−1+anΔt2x_{n+1} = 2x_n-x_{n-1} + a_n\Delta t^2

有意思的是:

这里甚至没有显式保存速度。

因为:

xn−xn−1x_n-x_{n-1}

本身就隐含了上一小段时间的运动趋势。

于是可以写成:

Vector3 current = position;

position =
    position +
    (position - previousPosition) +
    acceleration * dt * dt;

previousPosition = current;

position - previousPosition

可以看成近似的位移趋势。

因此 Verlet 给人的感觉非常漂亮:

物体不知道自己的“速度”是多少。

它只是记得自己上一刻在哪里。

然后根据:

过去的位置 + 当前受到的力

推算下一刻。

这套思想后来与 Position Based Dynamics 等方法产生了非常紧密的联系。

11RK4:既然可以算一次斜率,为什么不多看几次?

Euler 的核心思想是:

看当前的斜率,然后大胆往前走。

RK4,也就是:

Fourth-order Runge–Kutta

采取了更谨慎的方法。

它会在一个时间步内多次估计斜率:

k1k_1 k2k_2 k3k_3 k4k_4

最后综合:

yn+1=yn+Δt6(k1+2k2+2k3+k4)y_{n+1} = y_n+ \frac{\Delta t}{6} (k_1+2k_2+2k_3+k_4)

直觉上可以理解成:

Loading diagram…

相比 Euler:

看看现在
↓
直接走

RK4 更像:

看看现在
看看中间
再看看中间
看看未来附近
↓
综合决定怎么走

对于很多普通微分方程,它的精度非常优秀。

全局误差可以达到:

O(Δt4)O(\Delta t^4)

听起来似乎已经完胜 Euler。

可现实游戏物理并没有因此全部变成 RK4。

为什么?

12游戏物理追求的并不是“数学考试里的最高精度”

因为一个完整游戏世界里可能存在:

2000 个刚体
500 个 Joint
大量接触点
角色控制器
布料
车辆
破坏系统

假设一次物理状态评估需要处理整个世界。

Euler 可能只需要一次。

RK4 却需要多次评估。

成本一下子增加很多。

更麻烦的是,刚体物理存在大量:

  • 碰撞
  • 摩擦
  • 接触约束
  • Joint
  • 不连续事件

比如一个球:

这一刻:
     ●

下一刻:
──────── 地面

到底在什么时候接触?

一旦发生碰撞,速度可能突然改变:

v−→v+v^- \rightarrow v^+

这种系统并不像一个平滑函数那么优雅。

因此现代实时刚体物理的核心困难,很多时候并不是:

怎样精确积分一个光滑函数?

而是:

怎样稳定地处理大量接触和约束?

这就会进一步进入:

Constraint Solver。

例如:

  • Sequential Impulse
  • Projected Gauss–Seidel
  • XPBD
  • Jacobian
  • Lagrange Multiplier

于是你会发现:

游戏物理最深的部分,后来甚至已经不再像高中力学。

它逐渐变成了:

线性代数 + 数值分析 + 优化 + 约束求解。

13时间步长可能比积分器本身还重要

即使使用同一种积分器:

velocity += acceleration * dt;
position += velocity * dt;

不同的:

dt

也可能得到完全不同的结果。

假设:

Δt=0.001\Delta t=0.001

每一步很小。

我们对真实曲线的逼近通常比较好。

如果:

Δt=0.1\Delta t=0.1

那么每一步跨越的时间非常大。

局部线性近似就可能完全失效。

可以想象成沿着一条弯路行驶。

如果每隔 1 米重新调整方向:

· · · · · · ·

还能比较好地贴着道路。

如果每隔 100 米才调整一次:

●────────────●

你可能早就冲出山崖了。

因此很多游戏都会使用:

Fixed Timestep。

例如逻辑上保持:

Δt=150\Delta t = \frac{1}{50}

或者:

Δt=160\Delta t = \frac{1}{60}

而不是直接把渲染帧耗时作为物理步长。

Loading diagram…

这也是 Unity 中 FixedUpdate 这类概念背后的核心思想之一:

渲染帧率和物理模拟频率,并不是同一件事情。

14为什么 Variable Delta Time 容易制造麻烦?

考虑:

position += velocity * Time.deltaTime;

如果游戏稳定在:

60 FPS

那么:

Δt≈0.0167\Delta t\approx0.0167

但突然掉帧到:

15 FPS

那么:

Δt≈0.0667\Delta t\approx0.0667

一步突然变成原来的四倍。

这不仅意味着误差变大。

对于弹簧、Joint、约束等系统,甚至可能直接跨过稳定范围。

于是你会看到经典游戏物理事故:

平时:

      ●
     /|
──────┴──────

突然卡顿一帧:

                ●
              /
─────────────┴────────

然后:

💥 NaN

因此复杂物理系统通常非常在意:

最大时间步长。

必要时会进行:

Substepping。

例如这一帧:

dt=0.05dt=0.05

不要一次模拟:

0.050.05

而是拆成:

0.01+0.01+0.01+0.01+0.010.01+0.01+0.01+0.01+0.01

也就是:

一个大步
↓
多个小步

虽然计算量增加,但稳定性通常明显改善。

15“积分器”和“物理引擎”不是同一个东西

这里还有一个很容易混淆的概念。

一个完整的刚体物理 Step,大概更像:

Loading diagram…

实际物理引擎当然会复杂得多。

但可以看到:

Integrator 只是整个物理引擎中的一个组成部分。

真正困难的部分还包括:

  • Broad Phase
  • Narrow Phase
  • Contact Manifold
  • Friction
  • Joint Constraint
  • Solver
  • CCD
  • Sleeping
  • Island
  • Warm Starting

所以:

“PhysX 使用什么积分器?”

虽然是一个有意义的问题,但它绝不足以解释 PhysX 为什么稳定或者为什么性能好。

现代物理引擎的行为,是整个求解系统共同决定的。

16一个特别重要的认知:模拟不等于现实

很多游戏开发者刚开始学习物理时,会下意识认为:

只要把牛顿公式写对,游戏里的运动就一定是正确的。

实际上:

物理公式正确

和

数值模拟正确

是两个问题。

连续世界可能满足:

F=maF=ma

但你最终实现的是:

vn+1=vn+anΔtv_{n+1}=v_n+a_n\Delta t xn+1=xn+vn+1Δtx_{n+1}=x_n+v_{n+1}\Delta t

这是另一个数学系统。

它只是希望:

当:

Δt→0\Delta t\rightarrow0

时,它能够逼近原来的连续系统。

因此游戏里的物理并不是现实物理的复制品。

它更像是:

现实物理在有限计算资源下的一个离散模型。

这也是数值分析最迷人的地方之一。

17数学里的“正确”,其实有很多种

当我们评价一个积分器时,可以问很多完全不同的问题。

比如:

精度

一次模拟到底离真实值有多远?

稳定性

误差会不会随着时间不断爆炸?

能量行为

系统会不会凭空产生或损失能量?

计算成本

每一步需要多少次力计算?

时间可逆性

往前模拟再往后模拟,能不能回到原状态?

结构保持

是否保留原系统的一些几何性质?

因此不存在一个简单的:

“最好的积分器”。

不同问题,会需要不同的方法。

方法特点常见场景
Explicit Euler极其简单,但动态系统中可能不稳定教学、简单模拟
Semi-Implicit Euler简单、便宜、长期行为通常更适合机械系统实时游戏物理
Verlet位置形式自然,约束处理方便布料、绳子、粒子、PBD
RK4对光滑 ODE 精度高,但计算成本较高轨道、控制、科学模拟
Implicit Methods通常更稳定,但需要求解方程刚性系统、离线模拟

这里最有趣的一点是:

“精度最高”并不自动意味着“最适合游戏”。

游戏是实时系统。

它永远需要在:

Accuracy\text{Accuracy} Stability\text{Stability} Performance\text{Performance}

之间进行权衡。

18从 Euler 还能一路走到现代游戏物理

如果继续往下研究,会发现 Euler 只是入口。

Loading diagram…

这条路线非常有意思。

因为你可能最开始只是想写:

rigidbody.velocity += gravity * dt;

最后却一路进入:

  • 线性代数
  • 微积分
  • 微分方程
  • 数值分析
  • Hamilton 力学
  • 优化算法
  • 约束求解

游戏开发中的数学经常就是这样。

一个看似平凡的 API 背后,可能藏着几百年的数学。

19回到最开始的问题

所以:

为什么游戏里的“积分”不像高中数学里的积分?

因为高中数学经常讨论的是:

已知一个函数,求它的积分。

而游戏开发通常面对的是:

已知一个动态系统的变化规律,怎样让计算机一步一步模拟它未来会发生什么?

理论上我们写:

v(t)=v0+∫0ta(τ) dτv(t)=v_0+\int_0^t a(\tau)\,d\tau

计算机里我们写:

velocity += acceleration * dt;

理论上:

x(t)=x0+∫0tv(τ) dτx(t)=x_0+\int_0^t v(\tau)\,d\tau

计算机里:

position += velocity * dt;

微积分描述了:

世界应该怎样连续变化。

数值积分解决的是:

有限速度的计算机,怎样假装自己生活在那个连续世界里。

而 Semi-Implicit Euler 最有趣的地方就在这里:

velocity += acceleration * dt;
position += velocity * dt;

它看起来不过是两行平凡代码。

但这两行代码背后连接的,却是:

Newton、Euler、Hamilton、微分方程、相空间、数值稳定性,以及今天每秒运行几十次的游戏物理世界。

有时候,游戏开发里最有意思的数学,就是藏在这些我们每天都在写、却很少继续追问的代码里面。

CONTINUE EXPLORING

沿着这篇文章继续

在地图中查看

On this page