从零实现一个2D刚体物理引擎:数值积分、SAT碰撞检测与Sequential Impulse约束求解

举报
Snowplow5180 发表于 2026/09/26 22:20:31 2026/09/26
【摘要】 在上一篇文章中,我们从零实现了一个Markdown解析器,完成了块级状态机和行内分隔符栈的完整链路。那篇文章聚焦的是文本结构的解析。这一次我们把视角转向数值计算和几何算法,目标是一个更“物理”的系统:2D刚体物理引擎。物理引擎是游戏引擎、机器人仿真、交互式动画的底层支撑。它看起来复杂,但核心循环可以拆解为三个相对独立的问题:积分(给定力和速度,求解下一时刻的位置)、碰撞检测(判断两个物体是否...

在上一篇文章中,我们从零实现了一个Markdown解析器,完成了块级状态机和行内分隔符栈的完整链路。那篇文章聚焦的是文本结构的解析。这一次我们把视角转向数值计算和几何算法,目标是一个更“物理”的系统:2D刚体物理引擎。

物理引擎是游戏引擎、机器人仿真、交互式动画的底层支撑。它看起来复杂,但核心循环可以拆解为三个相对独立的问题:积分(给定力和速度,求解下一时刻的位置)、碰撞检测(判断两个物体是否相交,以及相交的具体信息)、约束求解(消除穿透、施加正确的冲量来模拟碰撞响应)。matter.js、p2.js、planck.js这些成熟的JS物理引擎,本质上都是在解决这三个问题,差异在于算法的精度、稳定性和工程复杂度。

本文用纯前端JavaScript从零实现一个支持凸多边形和圆的2D刚体物理引擎,不依赖任何库。完整链路是:Semi-implicit Euler积分 → SAT分离轴碰撞检测 → 接触流形生成 → Sequential Impulse约束求解 → Canvas渲染。代码可直接在浏览器中运行。

一、物理引擎的核心循环
物理引擎的主循环可以概括为“检测→求解→积分”三步。每一步的输入输出如下:

检测阶段:给定当前所有物体的位置和旋转,找出所有相互接触的物体对,并为每一对生成接触流形(contact manifold)。接触流形包含接触点的位置、穿透深度和接触法线。

求解阶段:给定接触流形和物体的质量、惯量、速度,计算需要施加的冲量,使得物体在接触点处不再相互穿透,同时满足摩擦和恢复系数约束。

积分阶段:根据求解得到的冲量更新物体速度,然后根据速度更新位置和旋转。同时施加外力(如重力)并做阻尼处理。

planck.js的核心概念文档中把World描述为“bodies、fixtures和constraints的集合”,Solver负责“推进时间并求解接触和关节约束”。这个描述准确概括了物理引擎的职责边界:World是状态容器,Solver是算法核心,积分是状态推进。

二、刚体的状态表示与数值积分
一个2D刚体在平面上的状态可以用位置、旋转角、线速度和角速度来完整描述。质量属性包括质量、质心位置和转动惯量。

javascript
class RigidBody {
  constructor(options = {}) {
    // 位置与旋转
    this.position = { x: 0, y: 0 };
    this.angle = 0;
    this.velocity = { x: 0, y: 0 };
    this.angularVelocity = 0;
    this.force = { x: 0, y: 0 };
    this.torque = 0;

    // 质量属性
    this.mass = options.mass ?? 0;
    this.invMass = this.mass > 0 ? 1 / this.mass : 0;
    this.inertia = options.inertia ?? 0;
    this.invInertia = this.inertia > 0 ? 1 / this.inertia : 0;

    // 形状
    this.shape = options.shape ?? null;
    this.restitution = options.restitution ?? 0.2;  // 恢复系数
    this.friction = options.friction ?? 0.3;         // 摩擦系数
    this.isStatic = options.isStatic ?? false;
  }

  applyForce(fx, fy) {
    this.force.x += fx;
    this.force.y += fy;
  }

  applyImpulse(ix, iy, contactVector = { x: 0, y: 0 }) {
    // 线速度变化
    this.velocity.x += ix * this.invMass;
    this.velocity.y += iy * this.invMass;
    // 角速度变化:冲量 × 接触点相对质心的叉积
    this.angularVelocity += this.invInertia *
      (contactVector.x * iy - contactVector.y * ix);
  }
}
积分使用Semi-implicit Euler方法:先用当前加速度更新速度,再用更新后的速度更新位置。与显式Euler相比,Semi-implicit Euler在弹簧和碰撞场景下更稳定,因为它引入了数值阻尼,能抑制能量的指数增长。

javascript
function integrate(body, dt, gravity) {
  if (body.isStatic || body.invMass === 0) return;

  // 施加外力(重力)
  body.force.x += gravity.x * body.mass;
  body.force.y += gravity.y * body.mass;

  // 1. 用当前加速度更新速度
  body.velocity.x += body.force.x * body.invMass * dt;
  body.velocity.y += body.force.y * body.invMass * dt;
  body.angularVelocity += body.torque * body.invInertia * dt;

  // 2. 阻尼(模拟空气阻力,防止速度无限增长)
  body.velocity.x *= 0.998;
  body.velocity.y *= 0.998;
  body.angularVelocity *= 0.998;

  // 3. 用更新后的速度更新位置
  body.position.x += body.velocity.x * dt;
  body.position.y += body.velocity.y * dt;
  body.angle += body.angularVelocity * dt;

  // 4. 清除累积的力和力矩
  body.force.x = 0;
  body.force.y = 0;
  body.torque = 0;
}
转动惯量取决于形状。对于质量为m、边长为s的正方形,绕质心的转动惯量为m * s^2 / 6。对于半径为r的圆,为m * r^2 / 2。这些值在创建刚体时根据形状自动计算。

三、碰撞检测:SAT分离轴定理
碰撞检测分为宽相和窄相两个阶段。宽相用AABB(轴对齐包围盒)快速排除明显不相交的物体对,窄相用精确算法判断是否真正相交并生成接触信息。本文实现的是窄相中的核心算法——分离轴定理(Separating Axis Theorem,SAT)。

SAT的数学基础很简单:两个凸多边形不相交,当且仅当存在一条轴,使得两个多边形在该轴上的投影区间不重叠。对于凸多边形,候选轴的数量是有限的——只需要检查每个多边形的每条边的法线方向。

javascript
function getVertices(body) {
  // 返回世界坐标系下的顶点列表
  const cos = Math.cos(body.angle);
  const sin = Math.sin(body.angle);
  return body.shape.vertices.map(v => ({
    x: body.position.x + v.x * cos - v.y * sin,
    y: body.position.y + v.x * sin + v.y * cos
  }));
}

function projectVertices(vertices, axis) {
  let min = Infinity, max = -Infinity;
  for (const v of vertices) {
    const proj = v.x * axis.x + v.y * axis.y;
    if (proj < min) min = proj;
    if (proj > max) max = proj;
  }
  return { min, max };
}

function satPolygons(polyA, polyB) {
  const vertsA = getVertices(polyA);
  const vertsB = getVertices(polyB);
  const axes = [];

  // 收集所有边的法线作为候选轴
  for (let i = 0; i < vertsA.length; i++) {
    const j = (i + 1) % vertsA.length;
    const edge = { x: vertsA[j].x - vertsA[i].x, y: vertsA[j].y - vertsA[i].y };
    axes.push({ x: -edge.y, y: edge.x }); // 法线
  }
  for (let i = 0; i < vertsB.length; i++) {
    const j = (i + 1) % vertsB.length;
    const edge = { x: vertsB[j].x - vertsB[i].x, y: vertsB[j].y - vertsB[i].y };
    axes.push({ x: -edge.y, y: edge.x });
  }

  let minOverlap = Infinity;
  let bestAxis = null;

  for (const axis of axes) {
    // 归一化轴向量
    const len = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
    const normAxis = { x: axis.x / len, y: axis.y / len };

    const projA = projectVertices(vertsA, normAxis);
    const projB = projectVertices(vertsB, normAxis);

    // 检查投影是否重叠
    if (projA.max < projB.min || projB.max < projA.min) {
      return null; // 存在分离轴,不相交
    }

    // 计算重叠深度
    const overlap = Math.min(projA.max - projB.min, projB.max - projA.min);
    if (overlap < minOverlap) {
      minOverlap = overlap;
      bestAxis = normAxis;
    }
  }

  // 确定法线方向:从A指向B
  const centerA = polyA.position;
  const centerB = polyB.position;
  const d = { x: centerB.x - centerA.x, y: centerB.y - centerA.y };
  if (d.x * bestAxis.x + d.y * bestAxis.y < 0) {
    bestAxis.x = -bestAxis.x;
    bestAxis.y = -bestAxis.y;
  }

  return { normal: bestAxis, depth: minOverlap };
}
SAT返回的depth是最小平移距离——如果要把两个多边形分开,沿着normal方向平移depth距离即可。这个信息在约束求解中直接用于计算恢复冲量。

圆与凸多边形的碰撞检测需要特殊处理。圆不是多边形,没有边。做法是:找到多边形上距离圆心最近的顶点或边,计算圆心到该点的距离。如果距离小于半径,则发生碰撞。

javascript
function satCirclePolygon(circleBody, polyBody) {
  const center = circleBody.position;
  const radius = circleBody.shape.radius;
  const verts = getVertices(polyBody);

  // 找到多边形上距离圆心最近的边
  let minDist = Infinity;
  let closestPoint = null;

  for (let i = 0; i < verts.length; i++) {
    const j = (i + 1) % verts.length;
    const point = closestPointOnSegment(center, verts[i], verts[j]);
    const dist = distance(center, point);
    if (dist < minDist) {
      minDist = dist;
      closestPoint = point;
    }
  }

  if (minDist >= radius) return null;

  const normal = normalize({
    x: closestPoint.x - center.x,
    y: closestPoint.y - center.y
  });

  return {
    normal,
    depth: radius - minDist,
    contactPoint: closestPoint
  };
}
四、接触流形与Sequential Impulse求解器
SAT给出的是几何信息——法线和深度。约束求解需要的是接触流形:在接触点处,两个物体的相对速度、有效质量,以及需要施加的冲量。

Sequential Impulse是Erin Catto(Box2D作者)提出的迭代求解方法,本质上是投影高斯-赛德尔(Projected Gauss-Seidel) 的一种矩阵自由形式实现。Bullet物理引擎的btSequentialImpulseConstraintSolver正是采用了这一方法,官方文档描述它为“Projected Gauss Seidel (iterative LCP) method”的快速实现。

算法的核心思想是:对每个接触点,计算一个冲量,使得接触点处的相对法向速度变为零(或按恢复系数反弹)。然后迭代这个过程多次,每次迭代都会修正之前冲量引入的误差,最终收敛到满足所有接触约束的速度场。

javascript
function solveContact(contact, dt) {
  const { bodyA, bodyB, normal, depth, contactPoint } = contact;

  // 接触点相对质心的向量
  const ra = { x: contactPoint.x - bodyA.position.x, y: contactPoint.y - bodyA.position.y };
  const rb = { x: contactPoint.x - bodyB.position.x, y: contactPoint.y - bodyB.position.y };

  // 接触点处的相对速度
  const relativeVelocity = {
    x: (bodyB.velocity.x - bodyB.angularVelocity * rb.y) -
       (bodyA.velocity.x - bodyA.angularVelocity * ra.y),
    y: (bodyB.velocity.y + bodyB.angularVelocity * rb.x) -
       (bodyA.velocity.y + bodyA.angularVelocity * ra.x)
  };

  // 法向相对速度
  const velAlongNormal = relativeVelocity.x * normal.x + relativeVelocity.y * normal.y;

  // 如果物体正在分离,不施加冲量
  if (velAlongNormal > 0) return;

  // 恢复系数
  const e = Math.min(bodyA.restitution, bodyB.restitution);

  // 计算有效质量(标量)
  const raCrossN = ra.x * normal.y - ra.y * normal.x;
  const rbCrossN = rb.x * normal.y - rb.y * normal.x;
  const invMassSum = bodyA.invMass + bodyB.invMass +
    raCrossN * raCrossN * bodyA.invInertia +
    rbCrossN * rbCrossN * bodyB.invInertia;

  // 施加的冲量大小
  const j = -(1 + e) * velAlongNormal / invMassSum;

  // 应用冲量
  applyImpulse(bodyA, -j * normal.x, -j * normal.y, ra);
  applyImpulse(bodyB, j * normal.x, j * normal.y, rb);

  // 位置修正(Baumgarte稳定化):消除穿透
  const percent = 0.4;       // 修正比例
  const slop = 0.01;         // 允许的微小穿透
  const correction = Math.max(depth - slop, 0) / invMassSum * percent;
  bodyA.position.x -= correction * normal.x * bodyA.invMass;
  bodyA.position.y -= correction * normal.y * bodyA.invMass;
  bodyB.position.x += correction * normal.x * bodyB.invMass;
  bodyB.position.y += correction * normal.y * bodyB.invMass;
}

function solveContacts(contacts, dt, iterations = 10) {
  for (let i = 0; i < iterations; i++) {
    for (const contact of contacts) {
      solveContact(contact, dt);
    }
  }
}
这段代码中有几个关键设计。velAlongNormal > 0的判断避免了在物体正在分离时施加不必要的冲量,这是维持稳定性的重要条件。invMassSum的计算包含了角惯量的贡献——当冲量施加在偏离质心的位置时,一部分能量会转化为角速度,有效质量因此增大,所需的冲量变小。

Baumgarte稳定化是消除穿透的标准技术。纯速度求解无法完全消除穿透,因为速度冲量只能改变未来的运动,不能修正已经发生的重叠。correction项直接将物体沿法线方向推开一个与穿透深度成正比的量,percent控制修正强度(通常0.2到0.8),slop允许微小的穿透以避免抖动。

五、摩擦力的处理
摩擦力是物理引擎中最难处理的部分之一。标准的做法是在求解法向冲量之后,沿接触面的切线方向施加一个冲量,其大小受库仑摩擦定律约束:|j_t| <= μ * |j_n|。

javascript
function solveFriction(contact, dt) {
  const { bodyA, bodyB, normal, contactPoint } = contact;
  const ra = { x: contactPoint.x - bodyA.position.x, y: contactPoint.y - bodyA.position.y };
  const rb = { x: contactPoint.x - bodyB.position.x, y: contactPoint.y - bodyB.position.y };

  // 计算切线方向(法线逆时针旋转90度)
  const tangent = { x: -normal.y, y: normal.x };

  const relativeVelocity = {
    x: (bodyB.velocity.x - bodyB.angularVelocity * rb.y) -
       (bodyA.velocity.x - bodyA.angularVelocity * ra.y),
    y: (bodyB.velocity.y + bodyB.angularVelocity * rb.x) -
       (bodyA.velocity.y + bodyA.angularVelocity * ra.x)
  };

  const velAlongTangent = relativeVelocity.x * tangent.x + relativeVelocity.y * tangent.y;

  const raCrossT = ra.x * tangent.y - ra.y * tangent.x;
  const rbCrossT = rb.x * tangent.y - rb.y * tangent.x;
  const invMassSum = bodyA.invMass + bodyB.invMass +
    raCrossT * raCrossT * bodyA.invInertia +
    rbCrossT * rbCrossT * bodyB.invInertia;

  let jt = -velAlongTangent / invMassSum;

  // 库仑摩擦约束:切向冲量不超过法向冲量 × 摩擦系数
  const mu = Math.sqrt(bodyA.friction * bodyB.friction);
  // 这里需要知道上一次法向冲量的大小,简化处理用深度近似
  const maxFriction = mu * Math.abs(contact.normalImpulse || 1);
  jt = Math.max(-maxFriction, Math.min(maxFriction, jt));

  applyImpulse(bodyA, -jt * tangent.x, -jt * tangent.y, ra);
  applyImpulse(bodyB, jt * tangent.x, jt * tangent.y, rb);
}
工程实现中,摩擦冲量的上限需要参考法向冲量。实际引擎(如Box2D、planck.js)会在求解器中存储每个接触点的法向冲量累积值,摩擦求解时引用这个值。上面的简化实现用contact.normalImpulse近似,在完整实现中需要在solveContact中把j存入contact对象。

六、碰撞检测的宽相优化
上面的SAT是窄相算法,对每一对物体都执行一次。如果场景中有N个物体,朴素做法是O(N²)对检测。对于几十个物体这还可以接受,但对于几百个物体的场景,宽相优化是必需的。

最简单的宽相优化是Sweep and Prune(排序扫描)算法。它的核心思想是:如果两个物体在x轴上的投影区间不重叠,它们一定不相交。将所有物体按AABB的最小x坐标排序,然后只需要检查排序后相邻的物体对。

javascript
function broadPhase(bodies) {
  // 1. 计算每个物体的AABB
  const entries = bodies.map((body, index) => {
    const aabb = computeAABB(body);
    return { body, index, minX: aabb.minX, maxX: aabb.maxX };
  });

  // 2. 按minX排序
  entries.sort((a, b) => a.minX - b.minX);

  // 3. 扫描:对每个物体,只检查minX小于其maxX的后续物体
  const pairs = [];
  for (let i = 0; i < entries.length; i++) {
    const a = entries[i];
    for (let j = i + 1; j < entries.length; j++) {
      const b = entries[j];
      if (b.minX > a.maxX) break; // 后续的minX更大,不可能重叠
      // 检查y轴重叠
      const aabbA = computeAABB(a.body);
      const aabbB = computeAABB(b.body);
      if (aabbA.minY <= aabbB.maxY && aabbB.minY <= aabbA.maxY) {
        pairs.push([a.body, b.body]);
      }
    }
  }
  return pairs;
}
Sweep and Prune将宽相复杂度从O(N²)降低到O(N log N + K),其中K是实际重叠的物体对数。在物体稀疏分布的典型场景中,K远小于N²。

p2.js作为成熟的JS物理引擎,其功能包括“collision detection, contacts, friction, restitution, motors, springs, advanced constraints and various shape types”。我们的实现覆盖了其中碰撞检测、接触、摩擦和恢复系数的核心部分,约束类型只实现了接触约束,还没有实现距离约束、旋转约束等关节类型。

七、完整物理世界与渲染
把所有部分串联起来,物理世界的更新循环如下:

javascript
class PhysicsWorld {
  constructor() {
    this.bodies = [];
    this.gravity = { x: 0, y: 9.81 * 100 }; // 像素尺度下的重力
    this.contacts = [];
  }

  addBody(body) { this.bodies.push(body); }

  step(dt) {
    // 1. 宽相检测
    const pairs = broadPhase(this.bodies.filter(b => !b.isStatic));

    // 2. 窄相检测 + 接触流形生成
    this.contacts = [];
    for (const [a, b] of pairs) {
      const contact = detectCollision(a, b);
      if (contact) this.contacts.push(contact);
    }

    // 3. 约束求解
    solveContacts(this.contacts, dt, 10);

    // 4. 积分
    for (const body of this.bodies) {
      integrate(body, dt, this.gravity);
    }
  }
}
渲染用Canvas 2D API。多边形用路径描边和填充,圆用arc。为了可视化旋转,可以在多边形上画一条从质心到某个顶点的线段。

javascript
function render(ctx, world) {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  for (const body of world.bodies) {
    ctx.save();
    ctx.translate(body.position.x, body.position.y);
    ctx.rotate(body.angle);

    if (body.shape.type === 'circle') {
      ctx.beginPath();
      ctx.arc(0, 0, body.shape.radius, 0, Math.PI * 2);
    } else {
      ctx.beginPath();
      const verts = body.shape.vertices;
      ctx.moveTo(verts[0].x, verts[0].y);
      for (let i = 1; i < verts.length; i++) {
        ctx.lineTo(verts[i].x, verts[i].y);
      }
      ctx.closePath();
    }

    ctx.fillStyle = body.color || '#4a90d9';
    ctx.fill();
    ctx.strokeStyle = '#222';
    ctx.stroke();

    // 绘制质心标记
    ctx.beginPath();
    ctx.arc(0, 0, 3, 0, Math.PI * 2);
    ctx.fillStyle = '#e74c3c';
    ctx.fill();

    ctx.restore();
  }
}
创建一个简单的场景来测试:

javascript
const world = new PhysicsWorld();

// 地面(静态)
world.addBody(new RigidBody({
  shape: { type: 'polygon', vertices: [
    { x: -400, y: 300 }, { x: 400, y: 300 },
    { x: 400, y: 350 }, { x: -400, y: 350 }
  ]},
  isStatic: true
}));

// 几个掉落的方块和圆
for (let i = 0; i < 5; i++) {
  const size = 40;
  world.addBody(new RigidBody({
    shape: { type: 'polygon', vertices: [
      { x: -size/2, y: -size/2 }, { x: size/2, y: -size/2 },
      { x: size/2, y: size/2 }, { x: -size/2, y: size/2 }
    ]},
    mass: 1,
    inertia: 1 * size * size / 6,
    position: { x: -100 + i * 60, y: -200 },
    color: `hsl(${i * 60}, 70%, 60%)`
  }));
}

// 主循环
let lastTime = performance.now();
function loop(now) {
  const dt = Math.min((now - lastTime) / 1000, 1/30); // 限制最大步长
  lastTime = now;
  world.step(dt);
  render(ctx, world);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
八、稳定性问题与工程考量
物理引擎的工程实现中,稳定性是最大的挑战。几个关键问题:

时间步长与子步。过大的dt会导致穿透和能量爆炸。标准做法是将物理更新固定为1/60秒,如果实际帧间隔更大,执行多次子步。planck.js的文档中特别提到了“sub-stepping solver”,它会“将物体移动到首次碰撞时间,然后求解碰撞”。

速度阈值与休眠。快速运动的物体会穿透薄壁(隧穿效应)。最简单的缓解是限制最大速度。更完整的方案是连续碰撞检测(CCD),在物体运动的路径上检测碰撞,而不是只检测离散时刻的位置。

堆叠稳定性。当多个物体叠在一起时,每个接触点的求解都会影响相邻的接触点,导致“抖动”。Sequential Impulse的迭代次数不够时,堆叠会缓慢下沉或抖动。增加迭代次数到15到20可以显著改善,但代价是性能。工程上通常用10次迭代,配合Baumgarte稳定化和位置修正来缓解。

角惯量的计算。对于任意凸多边形,转动惯量需要根据顶点坐标积分计算,而不是简单的公式。标准做法是将多边形分解为三角形,然后累加每个三角形对质心的惯量贡献。

九、总结
从刚体状态表示到Semi-implicit Euler积分,从SAT分离轴碰撞检测到接触流形生成,从Sequential Impulse约束求解到Baumgarte位置修正——这个2D物理引擎的核心代码不到400行,但覆盖了实时物理仿真的全部关键算法。

SAT的数学基础是凸集的分离定理,Sequential Impulse是投影高斯-赛德尔方法的矩阵自由实现,Semi-implicit Euler是辛积分器的一种。这些算法在游戏引擎、机器人仿真、交互式动画中反复出现,理解了它们的数学原理和工程实现,再去阅读Box2D、Bullet或PhysX的源码,会发现核心逻辑是相通的,差异只在优化策略——SIMD并行化、约束图的岛屿分割、连续碰撞检测的TOI算法。物理引擎的工程复杂度远远超出算法本身,但算法是所有工程优化的起点。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。