游戏物理引擎原理
概述
物理引擎模拟真实世界的力学规律:重力让物体下落、碰撞让物体弹开、约束让物体像被关节连接。理解物理引擎原理,不仅能更好地使用 Matter.js 等现成引擎,也能在需求简单时自己实现轻量物理,避免引入沉重依赖。本文从刚体模型出发,逐步构建碰撞检测、碰撞响应、力与约束,最后介绍四叉树优化。
相关阅读:物理引擎 介绍了 Matter.js 的使用与核心概念。
刚体模型
什么是刚体
**刚体(Rigid Body)**是物理引擎中最基本的模拟对象——一个不变形的固体,包含以下属性:
| 属性 | 含义 | 典型值 |
|---|---|---|
| 位置 position | 物体中心坐标 | |
| 速度 velocity | 每单位时间的位移 | |
| 质量 mass | 决定受力后的加速度 | 1 ~ 100 |
| 转动惯量 | 抵抗旋转的度量 | 由形状推导 |
| 恢复系数 restitution | 碰撞后反弹程度 | 0(无反弹)~ 1(完全反弹) |
| 摩擦系数 friction | 碰撞时的切向阻力 | 0 ~ 1 |
动态刚体与静态刚体
- 动态刚体:受重力、力、碰撞影响,会主动运动
- 静态刚体:位置固定(如地面、墙壁),仅作为碰撞对象,质量视为无穷大
// 刚体的 JS 表示
function createBody({ x, y, mass = 1, restitution = 0.5, friction = 0.3 }) {
return {
pos: { x, y },
vel: { x: 0, y: 0 },
force: { x: 0, y: 0 }, // 累加力
mass,
invMass: mass > 0 ? 1 / mass : 0, // 逆质量:静态刚体为 0
restitution,
friction,
isStatic: mass <= 0
}
}为什么用逆质量(invMass)
物理引擎中常使用逆质量而不是质量。质量无穷大(静态刚体)的逆质量为 0,这使所有公式可以统一处理动态与静态物体,无需特判分支。
积分:让刚体动起来
欧拉积分(Euler Integration)
每帧根据力和速度更新位置,是最常用的近似积分:
function integrate(body, dt) {
if (body.isStatic) return
// 1. 加速度 = 合力 / 质量(牛顿第二定律 F = ma)
const ax = body.force.x * body.invMass
const ay = body.force.y * body.invMass
// 2. 速度 += 加速度 * dt
body.vel.x += ax * dt
body.vel.y += ay * dt
// 3. 位置 += 速度 * dt
body.pos.x += body.vel.x * dt
body.pos.y += body.vel.y * dt
// 4. 清空每帧力(力是瞬时的)
body.force = { x: 0, y: 0 }
}局限性:欧拉积分精度有限,长时间模拟能量会不守恒(物体越跳越高或逐渐衰减)。对一般游戏足够,但要求高精度时可换用 Verlet 积分。
Verlet 积分
Verlet 不显式使用速度,而是基于上一帧位置推导,能量守恒更好,且天然稳定:
function verletIntegrate(body, prevPos, dt) {
// 新位置 = 2*当前位置 - 上一帧位置 + 加速度 * dt²
const ax = body.force.x * body.invMass
const ay = body.force.y * body.invMass
const newX = body.pos.x * 2 - prevPos.x + ax * dt * dt
const newY = body.pos.y * 2 - prevPos.y + ay * dt * dt
prevPos.x = body.pos.x
prevPos.y = body.pos.y
body.pos.x = newX
body.pos.y = newY
// 需要速度时(碰撞响应)可通过 (当前位置 - 上一帧位置) / dt 恢复
}重力
重力是最常见的力。每帧给所有动态刚体添加:
const GRAVITY = { x: 0, y: 9.8 * 100 } // px/s²
function applyGravity(body) {
body.force.x += GRAVITY.x * body.mass
body.force.y += GRAVITY.y * body.mass
}碰撞检测
物理引擎的碰撞检测分为两阶段(详见 游戏数学基础——碰撞检测):
- Broad Phase(粗检测):用空间分割(四叉树/网格)快速筛出可能的碰撞对
- Narrow Phase(精检测):对候选对做精确碰撞判定,计算碰撞点、法线、穿透深度
精确检测:圆与圆
function detectCircleCollision(a, b) {
const dx = b.pos.x - a.pos.x
const dy = b.pos.y - a.pos.y
const distSq = dx * dx + dy * dy
const rSum = a.radius + b.radius
if (distSq > rSum * rSum) return null // 未碰撞
const dist = Math.sqrt(distSq)
// 法线方向:从 a 指向 b 的单位向量
const normal = {
x: dist > 0 ? dx / dist : 1,
y: dist > 0 ? dy / dist : 0
}
return {
normal, // 碰撞法线(单位向量)
penetration: rSum - dist // 穿透深度
}
}精确检测:AABB 与 AABB
function detectAABBCollision(a, b) {
// a, b: { x, y, w, h }
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
if (overlapX <= 0 || overlapY <= 0) return null
// 取穿透较小的轴作为法线方向(更符合直觉)
if (overlapX < overlapY) {
const normal = { x: a.x < b.x ? -1 : 1, y: 0 }
return { normal, penetration: overlapX }
} else {
const normal = { x: 0, y: a.y < b.y ? -1 : 1 }
return { normal, penetration: overlapY }
}
}碰撞响应
检测到碰撞后,需要让物体分开并改变速度——这就是碰撞响应。
位置修正(避免物体陷入)
沿法线方向把穿透深度分配推开(按逆质量比例分配,静态物体不动):
function resolvePosition(a, b, normal, penetration) {
// 逆质量比例分配推开距离
const totalInv = a.invMass + b.invMass
if (totalInv === 0) return // 两个都是静态
const aRatio = a.invMass / totalInv
const bRatio = b.invMass / totalInv
a.pos.x -= normal.x * penetration * aRatio
a.pos.y -= normal.y * penetration * aRatio
b.pos.x += normal.x * penetration * bRatio
b.pos.y += normal.y * penetration * bRatio
}冲量法(Impulse)
碰撞响应改变物体的速度,经典做法是计算冲量 J:
function resolveVelocity(a, b, normal) {
// 相对速度沿法线的分量
const relVelX = b.vel.x - a.vel.x
const relVelY = b.vel.y - a.vel.y
const velAlongNormal = relVelX * normal.x + relVelY * normal.y
if (velAlongNormal > 0) return // 正在分离,无需响应
// 恢复系数决定反弹强度
const e = Math.min(a.restitution, b.restitution)
// 冲量大小(1D 弹性碰撞公式)
const j = -(1 + e) * velAlongNormal / (a.invMass + b.invMass)
const impulseX = j * normal.x
const impulseY = j * normal.y
a.vel.x -= impulseX * a.invMass
a.vel.y -= impulseY * a.invMass
b.vel.x += impulseX * b.invMass
b.vel.y += impulseY * b.invMass
}冲量公式推导:碰撞瞬间动量守恒 m1v1 + m2v2 = m1v1' + m2v2',结合恢复系数定义 (v2' - v1') = -e(v2 - v1),解出冲量 j = -(1+e)·vn / (invM1 + invM2)。
摩擦力
碰撞时的切向摩擦减小切向速度:
function resolveFriction(a, b, normal) {
const relVelX = b.vel.x - a.vel.x
const relVelY = b.vel.y - a.vel.y
// 切向速度 = 相对速度减去法向分量
const tangentX = relVelX - (relVelX * normal.x + relVelY * normal.y) * normal.x
const tangentY = relVelY - (relVelX * normal.x + relVelY * normal.y) * normal.y
const f = Math.min(a.friction, b.friction)
// 切向冲量(简化版)
a.vel.x -= tangentX * f
a.vel.y -= tangentY * f
b.vel.x += tangentX * f
b.vel.y += tangentY * f
}力与约束系统
力(Force)
力通过 body.force 累加,在积分时统一转换为加速度。常见的力包括:
- 重力:
F = m * g - 阻力/阻尼:
F = -k * v(与速度反向,模拟空气阻力) - 弹簧力:
F = -k * (x - rest)(胡克定律,拉回平衡位置)
弹簧约束
弹簧是最常见的约束——把两个物体连起来,力与伸长量成正比:
function applySpring(a, b, restLength, stiffness) {
const dx = b.pos.x - a.pos.x
const dy = b.pos.y - a.pos.y
const dist = Math.hypot(dx, dy) || 0.001
// 胡克定律:F = -k * (当前长度 - 自然长度),方向指向对方
const force = -stiffness * (dist - restLength)
const fx = (dx / dist) * force
const fy = (dy / dist) * force
a.force.x += fx
a.force.y += fy
b.force.x -= fx
b.force.y -= fy
}距离约束(Positional Constraint)
通过直接调整位置保证两物体间距离恒定,比弹簧更"硬":
function satisfyDistanceConstraint(a, b, restLength) {
const dx = b.pos.x - a.pos.x
const dy = b.pos.y - a.pos.y
const dist = Math.hypot(dx, dy) || 0.001
// 修正比例:把两端向中间拉回
const diff = (dist - restLength) / dist
const offsetX = dx * 0.5 * diff
const offsetY = dy * 0.5 * diff
if (!a.isStatic) { a.pos.x += offsetX; a.pos.y += offsetY }
if (!b.isStatic) { b.pos.x -= offsetX; b.pos.y -= offsetY }
}约束求解
真实物理引擎(如 Matter.js)将碰撞、关节等所有约束统一建模,通过迭代求解(每帧多次迭代逐步逼近精确解)。迭代次数越多越精确,但性能消耗越大。2~8 次迭代是常见折中。
四叉树优化
当场景物体很多时,O(n²) 的碰撞检测会迅速成为瓶颈。**四叉树(Quadtree)**把空间递归四等分,只检测同区域物体,将复杂度降到接近 O(n log n)。
四叉树结构
class Quadtree {
constructor(bounds, maxObjects = 4, depth = 0) {
this.bounds = bounds // { x, y, w, h }
this.objects = [] // 当前节点的物体
this.maxObjects = maxObjects // 超过则分裂
this.maxDepth = 6 // 最大深度
this.depth = depth
this.nodes = [] // 四个子节点
}
split() {
const { x, y, w, h } = this.bounds
const hw = w / 2, hh = h / 2
this.nodes = [
new Quadtree({ x, y, w: hw, h: hh }, this.maxObjects, this.depth + 1),
new Quadtree({ x: x + hw, y, w: hw, h: hh }, this.maxObjects, this.depth + 1),
new Quadtree({ x, y: y + hh, w: hw, h: hh }, this.maxObjects, this.depth + 1),
new Quadtree({ x: x + hw, y: y + hh, w: hw, h: hh }, this.maxObjects, this.depth + 1)
]
}
}插入与查询
class Quadtree {
// 把物体插入到合适的节点
insert(obj) {
// 有子节点则下放到对应子节点
if (this.nodes.length > 0) {
const index = this.getIndex(obj)
if (index !== -1) {
this.nodes[index].insert(obj)
return
}
}
this.objects.push(obj)
// 超过容量且还能分裂则分裂
if (this.objects.length > this.maxObjects && this.depth < this.maxDepth) {
if (this.nodes.length === 0) this.split()
// 把现有物体重新分配到子节点
for (const o of this.objects) {
const index = this.getIndex(o)
if (index !== -1) this.nodes[index].insert(o)
}
this.objects = this.objects.filter(o => {
return this.getIndex(o) === -1 // 跨界物体保留在当前节点
})
}
}
// 判断物体属于哪个象限(-1 表示跨界/不满足,留在本节点)
getIndex(obj) {
const { x, y, w, h } = this.bounds
const midX = x + w / 2
const midY = y + h / 2
const top = obj.y + obj.h < midY
const bottom = obj.y > midY
const left = obj.x + obj.w < midX
const right = obj.x > midX
if (top && left) return 0
if (top && right) return 1
if (bottom && left) return 2
if (bottom && right) return 3
return -1
}
// 查询与 obj 可能碰撞的所有物体
query(obj, result = []) {
for (const o of this.objects) result.push(o)
if (this.nodes.length > 0) {
const index = this.getIndex(obj)
if (index !== -1) {
this.nodes[index].query(obj, result)
} else {
// 跨界物体需查询所有子节点
for (const node of this.nodes) node.query(obj, result)
}
}
return result
}
}使用方式
// 每帧重建四叉树(或复用并清空)
const tree = new Quadtree({ x: 0, y: 0, w: 800, h: 600 })
for (const body of bodies) tree.insert(body)
// 对每个物体只与候选邻居做精确检测
for (const body of bodies) {
const candidates = tree.query(body)
for (const other of candidates) {
if (other === body) continue
const collision = detectCircleCollision(body, other)
if (collision) {
resolvePosition(body, other, collision.normal, collision.penetration)
resolveVelocity(body, other, collision.normal)
}
}
}完整的最小物理引擎
将以上部件组合成可运行的微型物理引擎(刚体小球受重力下落,相互碰撞并反弹):
本章小结
- 刚体是物理模拟的基本单元,用逆质量统一处理动态/静态物体
- 积分推进运动:欧拉简单、Verlet 稳定
- 碰撞检测分 Broad/Narrow 两阶段,计算法线与穿透深度
- 碰撞响应 = 位置修正(推开)+ 冲量法(改速度)+ 摩擦力
- 力与约束:重力、弹簧(胡克定律)、距离约束(迭代求解)
- 四叉树将碰撞检测从 O(n²) 优化到近 O(n log n)
下一篇文章将讲解游戏渲染基础——Canvas 2D、精灵批处理、瓦片地图与渲染管线对比。