跳转到内容

算法笔记:这套软体物理是怎么算出来的

首页那个能戳能甩的 playground,全部代码不到 700 行,核心只有五件事。 下面按求解顺序讲一遍,附本站的真实实现片段(完整源码在 GitHub 仓库src/scripts/engine.js)。

传统做法是「算受力 → 更新速度 → 更新位置」,软体一多就容易炸。 PBD(Position Based Dynamics)反过来:先让物体自由运动到一个错误的位置,再反复把位置修正回满足约束的地方, 速度不单独存,用「当前位置 − 上一步位置」隐式得到。

好处是永远不会因为弹簧太硬而数值爆炸——最坏情况只是约束没完全满足,看起来软一点。

一个子步长这样:

// ① 无约束积分(Verlet)
vx = (x - px) * damping;
px = x; x += vx;
py = y; y += vy + gravity * h * h;
// ② 反复投影约束(本站每子步迭代 3 轮,每帧 6 个子步)
solveSprings(); // 距离
solveArea(); // 面积 / 压强
solveShapeMatch(); // 形状匹配
solveCollisions(); // 碰撞推出
solveBounds(); // 地面和墙

首页「阻尼」滑块改的就是 damping,「重力」改的是 gravity。 阻尼调到 0.985 附近,戳一下两秒就停;调到 1.000,果冻能晃到你不耐烦。

场上没有「刚体」这种对象,只有一堆质点和它们之间的约束。 一根弹簧就是「这两个质点应该相距 rest」:

const diff = ((d - s.rest) / d) * k;
pa.x += dx * diff * wa;
pb.x -= dx * diff * wb;

k 就是首页的刚度滑块。调低,果冻会被自己的重量压扁;调到 1,几乎不可拉伸。 w 是质量倒数的归一化权重——钉死的质点 invMass = 0,永远不动,反作用力全给对方。

一个果冻球的连接方式是三层:相邻质点连一圈(撑住外形)、隔一个再连一圈(抗剪切)、 所有质点连向圆心(防止塌陷)。这对应原项目 README 里说的 internal springs and internal particles。

只有弹簧的软体会像一张湿抹布一样摊在地上。让它保持体积感的是面积约束

多边形的有向面积有闭式公式,对每个顶点求偏导也有闭式解:

面积: A = 0.5 * Σ ( x[i]*y[i+1] - x[i+1]*y[i] )
对顶点求导: dA/dp[i] = 0.5 * ( y[i+1]-y[i-1] , x[i-1]-x[i+1] )

按 PBD 的通用投影公式,把「当前面积 − 目标面积」这个误差沿梯度方向分配给每个顶点:

const C = currentArea - targetArea;
const lambda = -C / Σ(w_i * |grad_i);
p_i += lambda * w_i * grad_i;

首页的压强滑块改的就是 targetArea 的倍数。拉到 2.2,果冻会被吹成一个绷紧的气球, 弹性肉眼可见地变强;拉到 0.35,它会瘪下去、像块橡皮泥一样瘫着。 这就是原项目说的 area-volume preserving model。

3. 形状匹配:箱子为什么堆得住

Section titled “3. 形状匹配:箱子为什么堆得住”

弹簧和面积都只约束「局部关系」,物体整体可以慢慢扭成麻花。 形状匹配约束的是整体:每一步都求一个最贴合原始形状的旋转,再把质点往这个理想位置拉。

求最优旋转角有闭式解,不需要迭代:

// q 是当前相对质心的位置,r 是静止形状
D = Σ(q·r); S = Σ(q.y*r.x - q.x*r.y);
theta = Math.atan2(S, D);
goal_i = centroid + Rotate(theta) * r_i;
p_i += (goal_i - p_i) * alpha;

alpha 拉满 = 近似刚体,所以首页「箱子堆叠」那四层金字塔能稳住不倒; alpha 只给 0.04,就是有形状记忆的软体——被压扁之后还认得回家的路。

打开首页的线框开关,你会看到箱子其实也是 8 个质点连成的一团,只是它被形状匹配拽得很紧。

这里没有做冲量法,用的是最朴素的位置修正:

  1. AABB 粗筛,跳过不可能相交的物体对;
  2. 射线法判断某个质点是否落在另一个物体的多边形内部;
  3. 在内部,就找最近的那条边,把质点沿法线推到边上;
  4. 反作用力按重心坐标分给这条边的两个端点,按质量倒数加权——这样动量不会凭空产生;
  5. 顺手削掉一部分切向速度,这就是摩擦。没有它,箱子会像踩了香蕉皮一样滑走。

绳和链没有闭合轮廓,走的是另一条路:把每个质点当成小圆,用均匀网格做邻域查询, 重叠就互相推开。同一个物体内部的质点不互相碰撞,否则会跟弹簧打架。

鼠标抓取只有一行:把被抓的质点直接拉向指针。

p.x += (grab.x - p.x) * 0.55;

看着像作弊,但因为速度在 Verlet 里是「位置差」,你快速拖动时质点的位置差自然就变大, 松手那一刻它带着这个速度飞出去。扔和甩的手感是免费送的,这是 PBD 最讨人喜欢的一点。

诚实地列一下本站没有做的事,原项目都做了:

  • 凸多边形分割(原项目用 polypartition 处理任意凹多边形)
  • SAP 宽相位、休眠岛(sleeping islands)等性能优化
  • 光线投射(raycasting)、碰撞层掩码
  • 软体自碰撞(本站只做了物体之间的碰撞)
  • 连续碰撞检测、可破坏刚体、流体(这些在原项目的 roadmap 上)
  • 事件系统、UV 映射、Godot 引擎绑定

要认真做游戏,去用 原项目, 它有完整的 文档示例


原项目署名:QuarkPhysics © 2023 Eray Zesen,MIT License。 本站为独立的 JavaScript 再实现,用于把该引擎的算法效果搬到浏览器里直接体验。