算法笔记:这套软体物理是怎么算出来的
首页那个能戳能甩的 playground,全部代码不到 700 行,核心只有五件事。
下面按求解顺序讲一遍,附本站的真实实现片段(完整源码在
GitHub 仓库 的 src/scripts/engine.js)。
0. 整体骨架:PBD
Section titled “0. 整体骨架:PBD”传统做法是「算受力 → 更新速度 → 更新位置」,软体一多就容易炸。 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,果冻能晃到你不耐烦。
1. 质点与距离约束
Section titled “1. 质点与距离约束”场上没有「刚体」这种对象,只有一堆质点和它们之间的约束。 一根弹簧就是「这两个质点应该相距 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。
2. 面积约束:果冻为什么不塌
Section titled “2. 面积约束:果冻为什么不塌”只有弹簧的软体会像一张湿抹布一样摊在地上。让它保持体积感的是面积约束。
多边形的有向面积有闭式公式,对每个顶点求偏导也有闭式解:
面积: 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 个质点连成的一团,只是它被形状匹配拽得很紧。
4. 碰撞:掉进去就推出来
Section titled “4. 碰撞:掉进去就推出来”这里没有做冲量法,用的是最朴素的位置修正:
- AABB 粗筛,跳过不可能相交的物体对;
- 射线法判断某个质点是否落在另一个物体的多边形内部;
- 在内部,就找最近的那条边,把质点沿法线推到边上;
- 反作用力按重心坐标分给这条边的两个端点,按质量倒数加权——这样动量不会凭空产生;
- 顺手削掉一部分切向速度,这就是摩擦。没有它,箱子会像踩了香蕉皮一样滑走。
绳和链没有闭合轮廓,走的是另一条路:把每个质点当成小圆,用均匀网格做邻域查询, 重叠就互相推开。同一个物体内部的质点不互相碰撞,否则会跟弹簧打架。
5. 抓取:为什么甩得出去
Section titled “5. 抓取:为什么甩得出去”鼠标抓取只有一行:把被抓的质点直接拉向指针。
p.x += (grab.x - p.x) * 0.55;看着像作弊,但因为速度在 Verlet 里是「位置差」,你快速拖动时质点的位置差自然就变大, 松手那一刻它带着这个速度飞出去。扔和甩的手感是免费送的,这是 PBD 最讨人喜欢的一点。
和原引擎的差距
Section titled “和原引擎的差距”诚实地列一下本站没有做的事,原项目都做了:
- 凸多边形分割(原项目用 polypartition 处理任意凹多边形)
- SAP 宽相位、休眠岛(sleeping islands)等性能优化
- 光线投射(raycasting)、碰撞层掩码
- 软体自碰撞(本站只做了物体之间的碰撞)
- 连续碰撞检测、可破坏刚体、流体(这些在原项目的 roadmap 上)
- 事件系统、UV 映射、Godot 引擎绑定
原项目署名:QuarkPhysics © 2023 Eray Zesen,MIT License。 本站为独立的 JavaScript 再实现,用于把该引擎的算法效果搬到浏览器里直接体验。