主题
物理板块导览
游戏物理要解决的问题很具体:让手感像那么回事,同时不把帧率拖垮。它借用牛顿力学的公式,但目标不是复现真实世界,而是在每帧十几毫秒的预算里给出稳定、可预测、可调试的运动。本板块从物理量出发,一路走到碰撞、约束与 2D/3D 场景落地,共八页。
这一页是路线图与约定:先讲清"游戏物理 ≠ 真实物理",再给出八页地图、标准物理循环和单位约定,最后讨论一个工程问题——什么时候该用现成引擎,什么时候值得自己写。
游戏物理不是"真实物理"
真实物理追求正确,游戏物理追求可信 + 稳定 + 便宜。这三者的优先级经常高于精度,甚至高于自洽:一个物理上完全错误的跳跃,只要每台机器上都跳得一样高、掉帧时也不变矮,就是好跳跃。
| 关心的问题 | 真实物理的做法 | 游戏物理的做法 |
|---|---|---|
| 时间 | 连续时间,解析解或高精度数值解 | 固定步长的离散时间步 dt,允许误差 |
| 目标 | 精确复现实验数据 | 看起来对、手感好、可复现 |
| 代价 | 可以很贵(离线仿真、风洞) | 每帧预算固定,必须便宜且可预测 |
| 作弊 | 不允许 | 随时可以:速度钳制、额外阻尼、把玩家推力调大 |
允许"作弊"是游戏物理最实用的部分。几个常见手法:
- 速度钳制:
v = clamp(v, −vMax, vMax),防止穿模与数值爆炸; - 额外阻尼:给非玩家刚体加一点人造阻尼,避免永不停歇的抖动;
- 休眠:速度长期低于阈值就冻结刚体,既省 CPU 又消抖;
- 玩家特权:跳跃时临时给一段"土狼时间"或额外加速度,物理上不成立,手感上很成立;
- 参数优先:先定"跳 3 格高、滞空 0.6 秒",再反解重力与初速度,而不是照搬月面重力。
先问"要什么手感",再问"什么公式"
做跳跃时先定三个数:跳多高(米或像素)、滞空多久(秒)、能不能二段跳。有了这三个数就能反解出重力与初速度,比"先查真实重力"快得多。物理公式在这里是实现手感的手段,不是目标。
实时性、稳定性、可复现:三个硬约束
工程上真正约束物理实现的,不是牛顿定律,而是下面三件事:
- 实时性:一帧的物理预算通常只有 1~4 ms。碰撞检测用广相剔除、约束求解只迭代固定次数,都是为了把最坏情况压进预算。
- 稳定性:数值方法在步长过大时会发散。宁可让积分器"损失一点精度",也不要让物体突然飞出屏幕。
- 可复现:同一串输入必须得到同一串结果,否则回放、录像、联机同步全部失效。这要求固定步长,必要时连浮点都要换成定点数。
三条约束互相拉扯:想更稳就减小 dt(更贵),想更便宜就少迭代(更容易穿透),想可复现就不能"按时间自适应步长"。本板块后面每一页的取舍,基本都围绕这三点展开。
本板块地图
八页各有明确的问题域,前两页是地基,中间三页是通用的数值与碰撞技术,最后三页是场景落地:
| 页面 | 回答什么问题 |
|---|---|
| 物理基础 | 物理量与单位、牛顿定律、能量、常见力模型、参考系 |
| 力学基础 | 运动学公式、受力分析、动量与冲量、角运动、能量方法 |
| 数值积分与时间步进 | 为什么 x += v·dt 会漂移、四种积分器、固定步长与插值 |
| 碰撞检测与响应 | 穿透深度、冲量求解、恢复系数、稳定堆叠 |
| 2D 游戏物理 | 平台跳跃、斜坡、单向平台、角色控制器 |
| 3D 游戏物理 | 刚体姿态、四元数积分、接触约束、角色胶囊 |
| 粒子、弹簧与约束 | 粒子系统、弹簧网络、位置约束与 PBD |
| 几何检测(数学板块) | 最近点、相交检测、分离轴——物理的"眼睛" |
建议的阅读顺序就是表格顺序:先建立"物理量 + 力 + 能量"的直觉,再学运动学与动量,然后才是数值方法与碰撞。跳过前两页直接抄引擎参数,遇到奇怪现象时会完全没有排查方向。
三条常见的学习路径
不同目标需要的深度不一样,可以按下面三条路径挑着读:
| 你的目标 | 推荐顺序 | 可以先跳过 |
|---|---|---|
| 2D 平台跳跃角色控制器 | 物理基础 → 力学基础 → 数值积分 → 2D 游戏物理 | 3D 刚体姿态、粒子约束 |
| 3D 载具 / 抛掷物 / 布娃娃 | 物理基础 → 力学基础 → 数值积分 → 碰撞响应 → 3D 游戏物理 | 2D 角色控制器 |
| 纯特效(绳索、布料、飘带、粒子) | 物理基础(力模型)→ 数值积分 → 粒子、弹簧与约束 | 冲量求解、3D 接触约束 |
共同点是前两页与数值积分都不能跳:它们决定了你会不会写出"看起来能动但一卡就炸"的代码。
与其它板块的关系
物理板块夹在数学与渲染之间,输入是几何量,输出是下一帧的位置与姿态:
| 阶段 | 来自哪里 | 物理做什么 | 交给谁 |
|---|---|---|---|
| 表示运动 | 向量、矩阵与变换 | 位姿、速度、角速度的表示与更新 | 物理内部 |
| 时间与插值 | 插值、缓动与曲线 | 固定步长积分 + 渲染插值 | 渲染 |
| 判断相交 | 几何检测 | 用相交结果生成接触点与法线 | 碰撞响应 |
| 求解响应 | 本板块 | 冲量、约束、位置修正 | 渲染 |
换句话说:数学提供语言,几何提供眼睛,物理提供决策,渲染负责好看。同一个相交检测函数,在几何板块是"点一下选中了什么",在物理板块是"这两个物体接触了没有、法线朝哪、穿透多深"。分工清楚,才能避免在两处各推一遍公式、各写一份实现。
别在物理里重复造几何轮子
最近点、射线-三角形、AABB-圆形、凸多边形分离轴这些算法全部在几何检测里推导过了。物理页只讨论"怎么用这些结果生成接触与冲量",遇到公式请回去查那一页,不要在两处各写一遍。
先跑一个示例
下面这个示例把四种数值积分器放在同一个系统上对照:一个竖直悬挂的弹簧-质量振子,同时受重力和弹簧力。四条泳道并排显示弹簧与小球的运动,右侧是位移轨迹(虚线为解析解),面板实时给出总能量、能量相对误差与位置误差。
四种积分器的能量漂移显式 Euler 会注入能量、半隐式有界振荡、Verlet 更准、RK4 最准;拖动 dt 与刚度滑块观察发散
examples/physics/integration-comparison.jsts
/**
* 【游戏物理 · 物理基础】四种数值积分器的能量漂移对照
* ------------------------------------------------------------------
* 系统:竖直弹簧-质量振子(x 向下为正,从弹簧原长量起)加重力,
* m·x'' = m·g − k·x → a(x) = g − (k/m)·x
* 平衡位置 x_eq = m·g/k,角频率 ω = √(k/m)。取 x₀ = 0(原长)、v₀ = 1.2 m/s 向下,
* 解析解 x(t) = x_eq + (x₀ − x_eq)·cos(ωt) + (v₀/ω)·sin(ωt);
* 无阻尼时总能量 E = ½·m·v² + ½·k·x² − m·g·x 严格守恒,正好用来给积分器打分。
*
* 显式 Euler 系统性注入能量、很快发散;半隐式(辛)Euler 与速度 Verlet 的能量误差是
* 有界振荡(正比 dt、不随时间累积),Verlet 精度高一个量级以上;RK4 最准但每步 4 次受力。
* 四条泳道显示弹簧示意 + 位移轨迹(虚线为解析解),面板实时给出误差。
* 纯物理函数以具名导出,便于用 Node 直接做数值断言(见「物理基础」正文)。
*/
import { create2D, rafLoop, cleanupAll } from '../shared/runtime.js'
import { createPanel } from '../shared/ui.js'
export type MethodId = 'explicit' | 'semiImplicit' | 'verlet' | 'rk4'
/** 振子参数(SI 单位):刚度 k、质量 m、重力加速度 g */
export interface System { k: number; m: number; g: number }
/** x:相对弹簧原长的位移(向下为正,米);v:竖直速度(向下为正,米/秒) */
export interface State { x: number; v: number }
export interface MethodSpec {
id: MethodId; name: string; color: string; note: string
step: (state: State, dt: number, sys: System) => State
}
/** 加速度:a = (m·g − k·x)/m = g − (k/m)·x */
export const accel = (x: number, sys: System): number => sys.g - (sys.k * x) / sys.m
/** 显式(前向)Euler:位置与速度都用旧加速度更新 —— 会注入能量 */
export function stepExplicit(state: State, dt: number, sys: System): State {
return { x: state.x + state.v * dt, v: state.v + accel(state.x, sys) * dt }
}
/** 半隐式(辛)Euler:先更新速度,再用新速度更新位置 —— 游戏里的默认选择 */
export function stepSemiImplicit(state: State, dt: number, sys: System): State {
const v = state.v + accel(state.x, sys) * dt
return { x: state.x + v * dt, v }
}
/** 速度 Verlet:位置展开到二阶,速度取两端加速度的平均 */
export function stepVerlet(state: State, dt: number, sys: System): State {
const a1 = accel(state.x, sys)
const x = state.x + state.v * dt + 0.5 * a1 * dt * dt
return { x, v: state.v + 0.5 * (a1 + accel(x, sys)) * dt }
}
/** 经典 RK4:每步 4 次受力计算,换来的是一致的高精度 */
export function stepRK4(state: State, dt: number, sys: System): State {
const k1x = state.v
const k1v = accel(state.x, sys)
const k2x = state.v + 0.5 * dt * k1v
const k2v = accel(state.x + 0.5 * dt * k1x, sys)
const k3x = state.v + 0.5 * dt * k2v
const k3v = accel(state.x + 0.5 * dt * k2x, sys)
const k4x = state.v + dt * k3v
const k4v = accel(state.x + dt * k3x, sys)
return {
x: state.x + (dt / 6) * (k1x + 2 * k2x + 2 * k3x + k4x),
v: state.v + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v)
}
}
/** 四种积分器;顺序即画布上的泳道顺序 */
export const METHODS: readonly MethodSpec[] = [
{ id: 'explicit', name: '显式 Euler', color: '#f472b6', note: '能量注入', step: stepExplicit },
{ id: 'semiImplicit', name: '半隐式 Euler', color: '#7ee7ce', note: '有界振荡', step: stepSemiImplicit },
{ id: 'verlet', name: '速度 Verlet', color: '#7aa2ff', note: '位置显式', step: stepVerlet },
{ id: 'rk4', name: 'RK4', color: '#ffd166', note: '每步 4 次受力', step: stepRK4 }
]
/** 总能量:动能 + 弹簧势能 + 重力势能(x 向下为正,故重力势能是 −m·g·x) */
export const energy = (s: State, sys: System): number =>
0.5 * sys.m * s.v * s.v + 0.5 * sys.k * s.x * s.x - sys.m * sys.g * s.x
export const equilibrium = (sys: System): number => (sys.m * sys.g) / sys.k
export const omega = (sys: System): number => Math.sqrt(sys.k / sys.m)
/** 解析解:位移 x(t) 与速度 v(t)(由 x₀、v₀ 决定的简谐运动) */
export function analyticAt(t: number, sys: System, x0: number, v0: number): number {
const w = omega(sys)
const xe = equilibrium(sys)
return xe + (x0 - xe) * Math.cos(w * t) + (v0 / w) * Math.sin(w * t)
}
export function analyticVelocityAt(t: number, sys: System, x0: number, v0: number): number {
const w = omega(sys)
const xe = equilibrium(sys)
return -w * (x0 - xe) * Math.sin(w * t) + v0 * Math.cos(w * t)
}
/** 解析振幅 A = √((x₀ − x_eq)² + (v₀/ω)²) */
export const amplitude = (sys: System, x0: number, v0: number): number =>
Math.hypot(x0 - equilibrium(sys), v0 / omega(sys))
export interface RunResult { t: number; state: State; energy: number; analytic: number }
/** 用某个积分器推进 duration 秒(整数步)——页面里的收敛结论就是用它验证的 */
export function simulate(spec: MethodSpec, sys: System, x0: number, v0: number, dt: number, duration: number): RunResult {
const steps = Math.max(1, Math.round(duration / dt))
let state: State = { x: x0, v: v0 }
for (let i = 0; i < steps; i++) state = spec.step(state, dt, sys)
const t = steps * dt
return { t, state, energy: energy(state, sys), analytic: analyticAt(t, sys, x0, v0) }
}
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
export default function (container: HTMLElement): () => void {
const view = create2D(container, { width: 720, height: 470, background: '#0b1220' })
const { ctx } = view
const sys: System = { k: 60, m: 1, g: 9.8 }
const X0 = 0
const V0 = 1.2
const L0 = 0.26 // 弹簧"原长"在画布上的长度(米)
const WINDOW = 4 // 轨迹窗口(秒)
const LIMIT = 6 // |x| 超过它即视为发散(米)
interface Runner { spec: MethodSpec; state: State; history: Array<{ t: number; dev: number }>; exploded: boolean }
let dt = 1 / 60
let running = true
let showRef = true
let acc = 0
let clock = 0
let e0 = energy({ x: X0, v: V0 }, sys)
const runners: Runner[] = METHODS.map((spec) => ({
spec,
state: { x: X0, v: V0 },
history: [{ t: 0, dev: 0 }],
exploded: false
}))
/** 归一化位移:(x − x_eq)/A,解析解正好在 [−1, 1] 内振荡 */
const deviation = (s: State): number => (s.x - equilibrium(sys)) / amplitude(sys, X0, V0)
function reset(): void {
clock = 0
acc = 0
e0 = energy({ x: X0, v: V0 }, sys)
for (const r of runners) {
r.state = { x: X0, v: V0 }
r.history = [{ t: 0, dev: deviation(r.state) }]
r.exploded = false
}
}
/** 推进一步:四种积分器共用同一个 dt,才是公平对照 */
function advance(): void {
clock += dt
for (const r of runners) {
if (r.exploded) continue
const next = r.spec.step(r.state, dt, sys)
if (!Number.isFinite(next.x) || !Number.isFinite(next.v) || Math.abs(next.x) > LIMIT) {
r.exploded = true
continue
}
r.state = next
r.history.push({ t: clock, dev: deviation(next) })
while (r.history.length > 1 && r.history[0].t < clock - WINDOW) r.history.shift()
}
}
const panel = createPanel(container, { title: '积分器对照' })
const slider = (
label: string, min: number, max: number, step: number, value: number,
apply: (v: number) => void, format?: (v: number) => string
) => panel.slider({ label, min, max, step, value, format, onInput: (v) => { apply(v); reset() } })
slider('时间步 dt', 4, 66, 1, 17, (v) => (dt = v / 1000), (v) => `${v} ms ≈ 1/${Math.round(1000 / v)} s`)
slider('刚度 k(N/m)', 20, 200, 5, 60, (v) => (sys.k = v))
slider('重力 g(m/s²)', 0, 20, 0.2, 9.8, (v) => (sys.g = v))
panel.buttons([
{ label: '暂停', onActivate: () => (running = false) },
{ label: '继续', active: true, onActivate: () => (running = true) }
])
panel.button({ label: '重置', onClick: reset })
panel.checkbox({ label: '显示解析解', value: true, onChange: (v) => (showRef = v) })
const readouts = runners.map((r) => panel.readout(`${r.spec.name}:—`))
panel.note('m = 1 kg 固定,E₀ = ½·m·v₀² = 0.720 J。改参数会重置;显式 Euler 在 dt 偏大时会发散。')
/** 从 (x, y0) 到 (x, y1) 画一根锯齿弹簧 */
function drawSpring(x: number, y0: number, y1: number, color: string, faded: boolean): void {
const len = Math.max(6, y1 - y0)
const coils = 9
ctx.strokeStyle = faded ? 'rgba(148, 163, 184, 0.35)' : color
ctx.lineWidth = 1.6
ctx.beginPath()
ctx.moveTo(x, y0)
for (let i = 1; i <= coils * 2; i++) {
const widen = i === 0 || i === coils * 2 ? 0 : 1
ctx.lineTo(x + (i % 2 === 0 ? -6 : 6) * widen, y0 + (len * i) / (coils * 2))
}
ctx.stroke()
}
function draw(): void {
const { width: w, height: h } = view
const top = 30
const laneH = (h - top - 14) / runners.length
const colX = 104
const plotL = 178
const plotR = w - 16
const plotW = plotR - plotL
const xe = equilibrium(sys)
const amp = amplitude(sys, X0, V0)
// 竖直比例:让「原长 + 平衡拉伸 + 振幅」正好放进一条泳道
const scale = (laneH - 34) / Math.max(0.05, L0 + xe + amp)
ctx.fillStyle = '#0b1220'
ctx.fillRect(0, 0, w, h)
ctx.font = '12px ui-monospace, monospace'
ctx.fillStyle = 'rgba(230, 237, 247, 0.72)'
ctx.fillText(`m = ${sys.m} kg k = ${sys.k} N/m g = ${sys.g.toFixed(1)} m/s² dt = ${(dt * 1000).toFixed(1)} ms ω = ${omega(sys).toFixed(2)} rad/s`, 12, 18)
runners.forEach((r, i) => {
const laneTop = top + i * laneH
const laneMid = laneTop + laneH / 2
const anchorY = laneTop + 10
const rel = (energy(r.state, sys) - e0) / Math.abs(e0)
ctx.strokeStyle = 'rgba(122, 162, 255, 0.14)'
ctx.beginPath()
ctx.moveTo(12, laneTop + laneH); ctx.lineTo(w - 16, laneTop + laneH); ctx.stroke()
ctx.fillStyle = r.spec.color
ctx.fillText(r.spec.name, 12, laneTop + 15)
ctx.fillStyle = 'rgba(230, 237, 247, 0.5)'
ctx.fillText(r.exploded ? '已发散' : `ΔE/E₀ ${rel >= 0 ? '+' : ''}${(rel * 100).toFixed(2)}%`, 12, laneTop + 30)
// 弹簧 + 小球
const ballY = anchorY + (L0 + clamp(r.state.x, -0.12, LIMIT)) * scale
drawSpring(colX, anchorY, ballY, r.spec.color, r.exploded)
ctx.beginPath()
ctx.arc(colX, ballY, 7, 0, Math.PI * 2)
ctx.fillStyle = r.exploded ? 'rgba(148, 163, 184, 0.6)' : r.spec.color
ctx.fill()
// 轨迹窗口:横轴时间,纵轴归一化位移
const tx = (t: number): number => plotL + ((t - (clock - WINDOW)) / WINDOW) * plotW
const ty = (dev: number): number => laneMid - clamp(dev, -1.6, 1.6) * (laneH / 2 - 8)
ctx.save()
ctx.beginPath()
ctx.rect(plotL, laneTop, plotW, laneH)
ctx.clip()
ctx.strokeStyle = 'rgba(122, 162, 255, 0.2)'
ctx.beginPath()
ctx.moveTo(plotL, ty(0))
ctx.lineTo(plotR, ty(0))
ctx.stroke()
if (showRef) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'
ctx.lineWidth = 1.2
ctx.setLineDash([5, 4])
ctx.beginPath()
for (let s = 0; s <= 60; s++) {
const t = clock - WINDOW + (WINDOW * s) / 60
if (t < 0) continue
const dev = (analyticAt(t, sys, X0, V0) - xe) / amp
s === 0 ? ctx.moveTo(tx(t), ty(dev)) : ctx.lineTo(tx(t), ty(dev))
}
ctx.stroke()
ctx.setLineDash([])
}
ctx.strokeStyle = r.exploded ? 'rgba(148, 163, 184, 0.5)' : r.spec.color
ctx.lineWidth = 1.8
ctx.beginPath()
r.history.forEach((p, index) =>
index === 0 ? ctx.moveTo(tx(p.t), ty(p.dev)) : ctx.lineTo(tx(p.t), ty(p.dev))
)
ctx.stroke()
ctx.beginPath()
ctx.arc(tx(clock), ty(deviation(r.state)), 3.6, 0, Math.PI * 2)
ctx.fillStyle = r.exploded ? '#94a3b8' : r.spec.color
ctx.fill()
ctx.restore()
})
}
function updateReadouts(): void {
runners.forEach((r, i) => {
if (r.exploded) return readouts[i].set(`${r.spec.name}:已发散(|x| > ${LIMIT} m,${r.spec.note})`)
const e = energy(r.state, sys)
const rel = (e - e0) / Math.abs(e0)
const dx = Math.abs(r.state.x - analyticAt(clock, sys, X0, V0)) * 1000
readouts[i].set(
`${r.spec.name}|E = ${e.toFixed(4)} J ΔE/E₀ = ${rel >= 0 ? '+' : ''}${(rel * 100).toFixed(2)}% |Δx| = ${dx.toFixed(2)} mm`
)
})
}
const loop = rafLoop((_time, delta) => {
if (running) {
acc += Math.min(delta, 0.1)
let steps = 0
// 固定步长:不管帧率多少,物理都用同一个 dt 推进(最多 400 步,防止卡死)
while (acc >= dt && steps < 400) {
advance()
acc -= dt
steps++
}
if (steps >= 400) acc = 0
}
draw()
updateReadouts()
})
return cleanupAll(loop, view, panel)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
试着把 dt 拖到 60 ms:显式 Euler 会迅速冲出泳道并发散,半隐式与 Verlet 仍然稳定,只是轨迹与虚线逐渐错开。再把刚度 k 拉大,同样的 dt 会更快发散——这说明稳定性只取决于 ω·dt 这个乘积,而不是单独的 dt。
游戏物理的标准循环
几乎所有 2D/3D 物理引擎的一帧都可以拆成同一条流水线:
- 输入:收集玩家意图(推力、跳跃、刹车)与外部事件,转成力或冲量;
- 受力:累加所有力(重力、弹簧、阻力、玩家控制力),得到合力
F; - 积分:用
a = F/m把合力变成加速度,再推进速度与位置; - 碰撞检测:找出接触对,生成接触点、法线与穿透深度;
- 约束/冲量求解:迭代求解接触与关节约束,得到冲量或位置修正;
- 位置修正:把残余穿透推开,避免抖动与穿模;
- 渲染插值:物理固定步长推进,渲染帧率可能不同,用插值把两次物理状态混合。
用代码骨架表达,就是一个"清空力 → 累加力 → 积分 → 求解约束"的循环:
ts
// 固定步长循环:不管渲染帧率多少,物理永远以 dt 推进
const accum = { t: 0 }
function step(frameDelta: number): void {
accum.t += frameDelta
while (accum.t >= dt) {
for (const body of bodies) {
// 1) 受力:清空上一帧的力,再累加本帧的力
body.force.x = 0
body.force.y = 0
body.force.y += body.mass * G // 重力
body.force.x += drag(body.velocity, kd) // 阻力
// 2) 积分:半隐式 Euler(先速度后位置)
body.velocity.x += (body.force.x / body.mass) * dt
body.velocity.y += (body.force.y / body.mass) * dt
body.position.x += body.velocity.x * dt
body.position.y += body.velocity.y * dt
}
// 3) 碰撞检测 → 冲量求解 → 位置修正
const contacts = detectContacts(bodies)
solveContacts(contacts, iterations)
accum.t -= dt
}
// 4) 渲染插值:alpha ∈ [0, 1) 表示"距离上一个物理状态过了多久"
const alpha = accum.t / dt
for (const body of bodies) render(lerp(body.previous, body.position, alpha))
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
关键点是物理步长与渲染帧率解耦:物理必须固定 dt(可复现、稳定),渲染可以用插值补足任意帧率。把两者绑在一起,就是"帧率一掉、跳跃高度就变"的经典 bug。
顺序不能颠倒
"每个物体先清空力,再累加本帧的力"这两句话必须成对出现。漏掉清空,力会一帧帧累积,物体会莫名其妙地加速——这是自写物理最常见的第一个 bug。
这条流水线里每一步都有它的代价,值得单独说清楚:
- 累加力几乎免费,但它决定了后面所有步骤的输入,力写错整帧就错;
- 积分是 O(物体数) 的,最便宜,也最适合用子步进提高精度;
- 碰撞检测通常是整帧里最贵的部分,所以先广相(AABB / 网格 / BVH)剔除,再窄相精确求交;
- 约束求解是迭代的,迭代次数直接换稳定性:3 次能跑,10 次才不抖,20 次才不软;
- 位置修正是"作弊"的集中地:用
slop(允许的残余穿透)和修正比例换取不抖动; - 渲染插值只影响观感,不影响物理,但少了它就会看到"高帧率下物体一格一格跳"。
理解了这份代价清单,就知道性能优化该从哪里下手:先看碰撞检测的广相,再看约束迭代次数,最后才考虑换更贵的积分器。
单位与尺度约定
本站所有公式默认使用 SI 单位:长度用米(m)、时间用秒(s)、质量用千克(kg)、力用牛顿(N)。但 2D 游戏里位置通常是像素,所以需要一条明确的换算约定。
| 约定 | 取值 | 理由 |
|---|---|---|
| 长度基准 | 100 px = 1 m(可调,但全局统一) | 让 g = 9.8 这类常数直接可用,速度数值落在熟悉的量级 |
| 时间基准 | 秒,物理固定步长常见 dt = 1/60 s | 与帧率无关;物理常数都以秒定义 |
| 质量基准 | 玩家 70 kg、木箱 20 kg 之类 | 质量只通过密度与惯性影响手感,绝对值可以随意定 |
换算只有两步,但必须只做一次——在"进入物理"和"离开物理"的边界上:
ts
const PX_PER_M = 100 // 全局唯一的一份换算系数
const toMeters = (px: number): number => px / PX_PER_M
const toPixels = (m: number): number => m * PX_PER_M
// 物理内部永远用米与秒;只有输入与渲染在边界上换算
const gravity = 9.8 // m/s²
const jumpSpeed = Math.sqrt(2 * gravity * toMeters(120)) // 能跳过 120 px 所需的初速度
const jumpTime = (2 * jumpSpeed) / gravity // 滞空时间(秒)1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
尺度决定手感,也决定数值稳定性
1 px = 1 m 会让典型速度与重力的数值变得极大(重力 9.8 "像素/秒²" 几乎看不出移动),而 1 px = 1000 m 又会让数值小到丢精度。做完换算后检查一下:典型速度应落在 0.1 ~ 100 这个区间,不在就调基准。
把换算跑一遍,手感参数就出来了。假设目标跳高 120 px、PX_PER_M = 100:
- 跳高换成米:
1.2 m; - 反解初速度:
v₀ = √(2·g·h) = √(2 × 9.8 × 1.2) ≈ 4.85 m/s; - 滞空时间:
T = 2·v₀/g ≈ 0.99 s,也就是约 60 帧——数值合理,说明尺度选对了; - 若想让滞空变成 0.6 s,就固定
T反解g:g = 8·h/T² ≈ 26.7 m/s²,这是"手感重力",不必等于 9.8。
参数之间永远是可反解的,这正是先定手感目标的价值:它把"调参玄学"变成"解一个一元方程"。
术语速查
| 术语 | 含义 | 单位 / 备注 |
|---|---|---|
| 位置 / 位移 | 物体在哪里 / 移动了多少 | m,向量 |
| 速度 / 速率 | 位置的变化率 / 速度的大小 | m/s,速率是标量 |
| 加速度 | 速度的变化率 | m/s²,由合力决定 |
| 质量 / 重量 | 惯性大小 / 重力的大小 | kg / N(重量随 g 变,质量不变) |
| 动量 / 冲量 | p = m·v / J = F·Δt = Δp | kg·m/s / N·s(两者量纲相同) |
| 力矩 / 转动惯量 | τ = r × F / 转动惯性 | N·m / kg·m²(二维是标量,三维是张量) |
| 能量 | 做功的能力 | J,动能 ½mv²,势能 mgh |
单位是最好用的自检工具
公式写错时,先做量纲检查:等式两边的量纲必须一致。比如把"力"和"冲量"相加(N 与 N·s)立刻就能看出来,而单看数值往往看不出问题。
确定性:为什么步长必须固定
变步长(每帧用真实帧间隔当 dt)看起来更"准",实际上会带来三个问题:
- 不可复现:60 FPS 与 144 FPS 的轨迹不同,回放与联机同步直接失效;
- 不稳定:某帧卡顿导致
dt突然变大,弹簧或碰撞求解器可能一路发散; - 手感漂移:跳跃高度、刹车距离都随帧率变化,调参变成玄学。
固定步长 + 累加器是标准解法:物理永远以 dt 推进,渲染帧只决定"这一帧要推几步"和"用多大比例插值"。若单帧时间过长,还需要限制最大子步数(比如 5 步),宁可让游戏变慢,也不要让物理一次推进半秒。
自己写还是用引擎
先给结论:绝大多数项目应该直接用引擎(Box2D、Rapier、Bullet、Jolt,或引擎自带的物理模块),自己写的场合只有几种。
| 场合 | 建议 |
|---|---|
| 需要堆叠、关节、复合形状、稳定接触 | 直接用引擎,自己写的求解器做不稳 |
| 只需要角色移动 + 简单地形 | 自己写更省:一个 AABB 扫掠 + 斜坡处理就够 |
| 需要完全可复现的定点数物理(回放、联机) | 自己写,或用支持确定性模式的专业库 |
| 只是要做"看起来像物理"的动画(绳索摆动、飘带) | 用弹簧/粒子近似,别上刚体引擎 |
判断标准是需求里有没有"多物体互相推挤":有,就上引擎;只有"一个玩家和静态地形",自己写反而更可控。这也是本板块先讲基础、再讲碰撞与约束的原因——只有理解冲量与约束在干什么,才能在引擎给出奇怪结果时知道往哪调。
常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 掉帧时跳跃变矮 | 物理步长跟着渲染帧率走 | 固定步长 + 累加器,渲染侧插值 |
| 物体慢慢加速停不下来 | 每帧忘记清空力 | 每步开头把力归零 |
| 高速物体穿过墙壁 | 一步位移超过墙厚 | 子步进、连续碰撞检测或钳制速度 |
| 单位混乱导致重力夸张 | 像素当米、或换算做了两次 | 物理内部只用 SI,边界换算且只做一次 |
| 换台机器结果不一样 | 浮点 + 变步长 | 固定步长;严格一致时用定点数 |
| 参数怎么调都不对 | 没有明确的"手感目标量" | 先定跳高/滞空/射程,再反解参数 |