蛛网、蜘蛛与位置约束:拆解并数值验证 subprotocol/verlet-js
用十余组可复现的数值实验把 subprotocol/verlet-js 的 Spiderweb 示例拆开:位置 Verlet 的确切递推、距离约束的误差放大律与 0.5 稳定性阈值、角度约束的收缩率、以及这个示例为什么必须写 frame(16)。附一个把引擎搬进网页的实时验证实验台。
2013 年,Sub Protocol 发布了一个 300 行的 JavaScript 物理引擎 verlet-js。它只做三件事:质点、距离约束、角度约束。用这三样东西,它做出了会自己爬网的蜘蛛——例子里的蜘蛛起初非常擅长独自玩扭扭乐(Twister),但不太会爬行,作者的解法是按蜘蛛的原点与轴向切出象限,每条腿认领一个象限,然后在自己的象限里找落脚点。
这个例子值得读的原因不是效果,而是它小到可以一眼看完:没有空间划分、没有矩阵、没有质量、没有时间步,全部物理只发生在两个函数里——一个做积分,一个做约束修正。也正因为小,它的每一条行为都可以被算准,而不是“看着像”。
这篇文章做两件事:
- 逐行读出它的确切数学(不是“大致是 Verlet 积分”,而是写出递推式);
- 把引擎原样搬进 Node,用同一份代码跑十余组数值实验,逐条对照理论值。
页面里除了原示例,还挂了一个验证仪表盘:实时显示距离约束误差的中位数与 99 分位、相对应变、角度约束误差、每帧松弛次数,以及由 k/step 直接推出来的稳定性判定。右侧能把 step 从 16 拉到 1——你会亲眼看到整张网在几帧内炸掉。这不是 bug,是这篇文章第二部分要证明的必然结果。
来源:Sub Protocol,
subprotocol/verlet-js(README 声明采用 MIT License;截至 2026-10-02,仓库为 3792 stars)。 原文件:examples/spiderweb.html(本文第一部分的核心)与js/verlet-1.0.0.js(Browserify 打包后的引擎,本文页面内联的就是这一份)。 在线原版:https://subprotocol.com/verlet-js/examples/spiderweb.html 转载说明:页面内联的引擎代码与示例原型逐行取自上述 MIT 授权仓库,版权归原作者,仅保留原版权声明;数值实验、仪表盘与本文解读为独立完成。
第一部分:这个示例在做什么
1.1 四块积木
引擎的全部类型只有四个。
Particle 保存两个位置,这一点是理解一切的关键:
1
2
3
4
function Particle(pos) {
this.pos = (new Vec2()).mutableSet(pos);
this.lastPos = (new Vec2()).mutableSet(pos);
}
它没有 velocity 字段。速度是每次积分时现算出来的差值 pos - lastPos。所以在这套引擎里,“速度”的单位是 像素/帧,而不是像素/秒——这句话在第二部分会变成一条硬结论。
Composite 是一张粒子表加一张约束表;Constraint 有三个实现:DistanceConstraint(约束两点距离)、PinConstraint(把一点钉死)、AngleConstraint(约束三点夹角)。VerletJS 负责积分、绘制、鼠标交互。
1.2 frame():位置 Verlet 的确切形式
整个物理引擎的主循环只有这十几行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (i in particles) {
// calculate velocity
var velocity = particles[i].pos.sub(particles[i].lastPos).scale(this.friction);
// ground friction
if (particles[i].pos.y >= this.height-1 && velocity.length2() > 0.000001) {
var m = velocity.length();
velocity.x /= m; velocity.y /= m;
velocity.mutableScale(m*this.groundFriction);
}
// save last good state
particles[i].lastPos.mutableSet(particles[i].pos);
// gravity
particles[i].pos.mutableAdd(this.gravity);
// inertia
particles[i].pos.mutableAdd(velocity);
}
写成公式,它就是位置 Verlet 的一种特定离散化:
\[x_{n+1} = x_n + (x_n - x_{n-1})\,f + g\]其中 $f$ 是 friction,$g$ 是 gravity。请注意三件事:
friction是“每帧把速度乘一个数”,不是黏滞阻尼系数,也不是时间常数。它的衰减是严格的几何序列 $f^{\,n}$。gravity直接加到位置上。用标准记号,这意味着 $g = a\,\Delta t^2$ 被整体吸收进了一个常数,所以引擎里根本不存在 $\Delta t$。- 没有时间步、没有子步时间、没有帧率钳制。一次
frame()就是一次离散化推进,与真实经过了多少毫秒无关。
1.3 距离约束:一个可以手推的闭式
DistanceConstraint.relax() 是全文最值得读的五行:
1
2
3
4
5
6
7
DistanceConstraint.prototype.relax = function(stepCoef) {
var normal = this.a.pos.sub(this.b.pos);
var m = normal.length2();
normal.mutableScale(((this.distance*this.distance - m)/m)*this.stiffness*stepCoef);
this.a.pos.mutableAdd(normal);
this.b.pos.mutableSub(normal);
}
令 $s = p_a - p_b$,目标长度 $d$,修正系数 $k = \texttt{stiffness} \times \texttt{stepCoef}$。那么 normal 就是
两个端点各移动 $\pm n$,于是新的相对向量是 $s’ = s + 2n$:
\[s' = s\left(1 + 2k\left(\frac{d^2}{\lvert s\rvert^2} - 1\right)\right)\]这是一个精确的闭式,不是迭代近似。把它用在 $s = d$ 上:括号里为 1,$s’ = s$,正确解是不动点。把当前长度记为 $d(1+e)$($e$ 是相对误差),小 $e$ 展开后
\[e' \approx e\,(1 - 4k)\]即单次松弛把相对误差乘上 $(1-4k)$。 这个 4 是“两端各移动整个修正量”造成的——既不是 1 也不是 2,而是 4。于是立刻得到一个稳定性判据:
\[\lvert 1-4k\rvert < 1 \;\Longleftrightarrow\; 0 < k < 0.5\]这一条在第二部分会被实测精确验证到小数点。同时它解释了源码里一个容易看错的命名:stiffness 不是物理刚度,而是“每次松弛的修正比例”。示例里写 0.99 看起来很硬,但能不能用完全取决于它旁边那个 stepCoef。
1.4 角度约束:那个“转四次”的函数
1
2
3
4
5
6
7
8
9
10
11
12
AngleConstraint.prototype.relax = function(stepCoef) {
var angle = this.b.pos.angle2(this.a.pos, this.c.pos);
var diff = angle - this.angle;
if (diff <= -Math.PI) diff += 2*Math.PI;
else if (diff >= Math.PI) diff -= 2*Math.PI;
diff *= stepCoef*this.stiffness;
this.a.pos = this.a.pos.rotate(this.b.pos, diff);
this.c.pos = this.c.pos.rotate(this.b.pos, -diff);
this.b.pos = this.b.pos.rotate(this.a.pos, diff);
this.b.pos = this.b.pos.rotate(this.c.pos, -diff);
}
前两行是标准做法:以 b 为支点,把 a 和 c 反向旋转,从而改变夹角。但后两行又把 b 绕 a 转、再绕 c 转回去。这是一个启发式补偿:它想让“中点”也承担一部分修正,避免只有两端动、中间不动。代价是——它不保持质心,也不是一个作用量守恒的操作。第二部分会实测它在激进参数下把三点系统甩到几百像素外。
1.5 spiderweb():用余弦函数织网,再把静长度压到三成
这张网的几何完全由三角函数加抖动生成:
1
2
3
var theta = i*stride + Math.cos(i*0.4)*0.05 + Math.cos(i*0.05)*0.2;
var shrinkingRadius = radius - radiusStride*i + Math.cos(i*0.1)*20;
var offy = Math.cos(theta*2.1)*(radius/depth)*0.2;
$n = \texttt{segments} \times \texttt{depth}$ 个点排成一条向内的螺旋:角度按固定步进,但叠了 $\cos(0.4i)$ 与 $\cos(0.05i)$ 两项抖动,因此相邻两点的间距是忽大忽小的,靠近中心时甚至会缩到几个像素。半径线性收缩,再叠一个 $\cos(0.1i)$ 的起伏,最后整体加一个垂直方向的 offy 让网有厚度。
连接规则只有两条:连下一个点(环向),连下标加 segments 的点(径向辐条,因为步进正好是一整圈,所以它落在同一个角度、不同的圈上)。然后每 4 个点钉一个,最后一行是最关键的一个设计决定:
1
2
for (c in composite.constraints)
composite.constraints[c].distance *= tensor; // tensor = 0.3
所有约束的静长度被统一乘上 0.3。注意这不是“拉紧”,而是把整张网推到一个不可能被同时满足的构型上:如果所有约束都满足,这张网应该缩到原来的 30%,但外圈被 pin 钉死了,收不回去。第二部分会给出这个决定的定量后果。
1.6 spider() 与 crawl():象限朝向与落脚点搜索
蜘蛛是一个复合体:胸腔、头、腹部三点加两条体约束和一个体角约束,然后用循环长出 8 条腿,每条腿 4 段(静长 20 / 20 / 20 / 12 之类),关节角用 AngleConstraint 锁住。
crawl(leg) 是“会爬”的全部秘密:
- 用胸腔到头的方向算出朝向 $\theta$,构造两个边界向量 $\theta$ 与 $\theta + \pi/2$,再配合
flag1(左右)与flag2(前后)把平面切成四个象限; - 在蛛网里遍历落点,只保留落在本腿象限、且距胸腔 35–100 像素、且没被其他腿占用的点;
- 打乱候选,取第一个,给这条腿加一条目标长度为 0 的距离约束:
1
spider.constraints.push(new DistanceConstraint(spider.legs[leg], paths[0], 1, 0));
这条 distance = 0 的约束不是一个“刚性连接”,而是一个吸引子:每帧把脚往那个网节点拽一点。示例文档里“蜘蛛可以被从网上拉下来”之所以成立,也是因为它——脚只是被持续吸引,而不是被焊死。
第二部分:把引擎搬进 Node,逐条验证
方法
js/verlet-1.0.0.js 是 Browserify 打包的浏览器版本,它把构造函数挂到 window 上。所以在 Node 里只要提供一个 window 桩,再显式把这些名字搬到 globalThis(这一点很重要:lib/constraint.js 里裸写了一个 Vec2,在浏览器里靠隐式全局才成立,在 Node 里会直接 ReferenceError——这是原库的一处隐形依赖),就能跑真实引擎:
1
2
3
const win = {}; global.window = win; global.self = win;
(0, eval)(bundleSource);
for (const k of Object.keys(win)) globalThis[k] = win[k];
示例里的 spider、spiderweb、crawl 三个原型定义在 HTML 页面里,从页面源码中取出那段 script 一并执行即可。下面是真实引擎的输出,不是重写实现。
总览
| # | 验证项 | 结果 | 结论 |
|---|---|---|---|
| 1 | 位置 Verlet 递推 | 160 帧偏差 0.000e+0 px | 与解析递推逐位一致,无隐式 dt |
| 2 | friction 的语义 | v100/v1 = 0.99^99 精确吻合 | 每帧乘性阻尼,非物理黏滞 |
| 3 | 地板与地面摩擦 | 停在地板 h-1;落地后每帧 ×0.8 | groundFriction 与帧率绑定 |
| 4 | 距离约束误差放大律 | 实测与闭式 8 位小数吻合 | 单次松弛误差 ×(1−4k) |
| 5 | 稳定性阈值 | 0.49 收敛 / 0.51 爆炸 | 阈值严格在 k = 0.5 |
| 6 | frame(step) 的真实语义 | 近线性区符合 (1−4k/step)^step | step 不是时间步,是松弛次数 |
| 7 | “刚性”杆有多刚 | 100 px 杆稳态误差 0.075 px | 0.075%,不是 0 |
| 8 | 约束的负载相关伸长 | 与重力、负载近似成正比 | 这份实现表现出有限的有效柔度 |
| 9 | 重合粒子 | (NaN, NaN) | 除零无保护 |
| 10 | 边界处理 | 无天花板,粒子飞出画布 | 只夹地板与左右墙 |
| 11 | 帧率耦合 | 下落 550 px 需 84 帧 | 120 Hz 屏上快一倍 |
| 12 | 角度约束收缩率 | 0.90244 vs 1−4k = 0.90000 | 与距离约束同一条定律 |
| 13 | 角度约束的动量 | coef = 1 时质心漂移 6.6e108 px | 非守恒算子,必须限制 coef |
| 14 | 规模与开销 | 175 粒子 / 351 约束 / 0.55 ms | 每帧 5616 次松弛 |
| 15 | 整例稳定性扫描 | step <= 3 发散,step >= 4 有界 | 量化了 frame(16) 的必要性 |
| 16 | tensor 的真实作用 | 中位相对应变 1.55% → 97% | 把网推到不可满足构型 |
| 17 | 蜘蛛腿部形变 | 段长 −8% ~ +24%,关节角偏 40–65° | 画面可信,但身体不刚 |
1. 位置 Verlet 与解析递推逐位一致
无约束自由粒子,初速 (1, 0),friction = 1,gravity = (0, 0.2),与手写递推 $x_{n+1} = x_n + (x_n - x_{n-1}) + g$ 对照:
1
2
3
第 1 帧:引擎 (1001.000000, 1000.200000) 递推 (1001.000000, 1000.200000)
160 帧后与解析递推的最大偏差 = 0.000e+0 px
末态:x=1160.00(匀速 1 px/帧 × 160) y=3576.00(1000 + 0.1·n(n+1))
偏差是精确的 0(同一浮点算术)。这坐实了上文的判断:引擎里没有 dt,gravity 就是 $a\,\Delta t^2$,而 $\Delta t$ 被永久地固化成了 1。
2. friction 是每帧乘性阻尼
关掉重力,给一个初速,数 100 帧:
1
2
3
friction=1 v100/v1 = 1.000000e+0 理论 1^99 = 1.000000e+0
friction=0.99 v100/v1 = 3.697296e-1 理论 0.99^99 = 3.697296e-1
friction=0.95 v100/v1 = 6.232136e-3 理论 0.95^99 = 6.232136e-3
六位有效数字完全一致。所以示例默认的 friction = 0.99 的含义是:速度每帧衰减 1%,剩下的 99% 保留。这是“每帧”而非“每秒”,所以在 120 Hz 屏幕上,同一个动画的阻尼会强一倍。
3. 地板、地面摩擦,以及一个容易过度解读的参数
bounds() 只做三件事:把低于地板的点抬回来、把左右越界点夹回来。实测粒子精确停在 height - 1 = 499 上。但更值得注意的是 groundFriction = 0.8:
1
落地后水平速度:第1帧 vx=10.0000 第3帧 vx=10.0000 第10帧 vx=2.0972 第30帧 vx=0.0242
也就是接触地板的粒子,速度每帧乘 0.8——20% 的速度损失,与速度大小无关,约 30 帧后彻底静止。把它当“摩擦系数”来调参是危险的:它同时作用于竖直分量,并且在 120 Hz 下会变成两倍强的“刹车”。
4–5. 误差放大律与 0.5 阈值
把 $s = d(1+e)$、$e = 1\%$ 代进闭式,只做一次松弛:
| k | 实测 e’ | 闭式预测 e’ | 线性放大 1−4k |
|---|---|---|---|
| 0.05 | 0.00800990 | 0.00800990 | 0.80 |
| 0.20 | 0.00203960 | 0.00203960 | 0.20 |
| 0.30 | −0.00194059 | −0.00194059 | −0.20 |
| 0.60 | −0.01388119 | −0.01388119 | −1.40 |
| 0.99 | −0.02940396 | −0.02940396 | −2.96 |
实测与闭式逐位吻合。两个可直接使用的读数:
- $k < 0.25$ 时误差单调减小且不过冲;$k > 0.25$ 后开始过冲并换号($k = 0.3$ 时 +1% 变成 −0.19%)。
- $k \to 0.5$ 时放大因子趋近 −1:误差不衰减,只是原地翻号振荡。
于是把它迭代 200 次,阈值处应该“瞬变”:
| k | 初始误差 | 200 次后误差 | 判定 |
|---|---|---|---|
| 0.40 | 2.000e+0 | 1.421e-14 | 收敛 |
| 0.49 | 2.000e+0 | 5.633e-4 | 收敛(很慢) |
| 0.50 | 2.000e+0 | 2.000e+0 | 纹丝不动 |
| 0.51 | 2.000e+0 | 2.068e+2 | 发散 |
| 0.99 | 2.000e+0 | 1.856e+3 | 发散 |
阈值落在 0.49 与 0.51 之间,与解析结论 $k < 0.5$ 完全一致。这个 0.5 是这篇文章里最有实用价值的一个数:只要你知道自己每个子步用的系数,就知道系统会不会炸。
6. frame(step) 到底在做什么
1
2
3
4
var stepCoef = 1/step;
for (i=0;i<step;++i)
for (j in constraints)
constraints[j].relax(stepCoef);
step 不是时间步。它是一次 frame() 里把全部约束重复松弛多少轮,而每一轮的系数被除以 step。在约束已经接近满足、可以使用上一节线性化结果时,每次 frame() 的误差总收缩率近似为
实测($k = 0.99$,初始误差 2 px):
| step | 等效系数 | 一帧后残余误差 | 说明 |
|---|---|---|---|
| 1 | 0.9900 | 5.842e+0 | 比初始还大,已发散 |
| 2 | 0.4950 | 1.920e+0 | 仍在阈值边缘 |
| 4 | 0.2475 | 4.019e-8 | 与 $(1-0.99)^4$ 吻合,最优 |
| 8 | 0.1237 | 8.627e-3 | 与 $(0.5051)^8$ 吻合 |
| 16 | 0.0619 | 2.142e-2 | 示例取值 |
| 32 | 0.0309 | 2.951e-2 | 比 16 还差 |
这张表有两个反直觉之处,两个都被公式解释了:
- 收缩率不是
step的单调函数。 它形如 $c^{\,s}$,而 $c = 1-4k/s$ 随 $s$ 增大而趋近 1,两者竞争的最小值出现在 $4k/s \to 1$ 附近,也就是 $s \approx 4k$。示例里 $k \approx 1$,所以最优在step = 4附近,残余误差能做到 $10^{-8}$ 量级。 - 示例的
step = 16并不是精度最优,而是余量最大。 每轮系数被压到 0.062,距 0.5 的安全上限有 8 倍余量——当crawl()把一条腿瞬移几十像素、或用户把节点拖到远处造成巨大的瞬时误差时,小系数不会把这次大误差放大。这是“用精度换鲁棒”的典型取舍,也解释了为什么把它改成frame(1)会直接爆炸(见第 15 条)。
7–8. “刚性”约束有多刚
距离约束在数学上是把它当成硬约束处理的,但实际实现是一个软投影。刚性杆单摆,在示例参数(step=16, k=0.99)下跑 20000 帧:
1
2
100 px 刚性杆:稳态长度误差 最大 = 0.075007 px (0.0750%),平均 = 0.027723 px
每帧松弛的误差收缩率 (1-4k/step)^step = 1.0571e-2
0.075% 听起来很小,但它是稳态误差,不会随时间衰减——处于“每帧注入误差 / 每帧收缩 99%”的平衡上。当约束上挂着负载时,这个平衡误差会明显放大。用一条自由悬挂的链来量化,每段静长 30、k=0.99、step=16:
| 重力 g | 第 1 段伸长 | 第 4 段伸长 |
|---|---|---|
| 0.05 | 0.2% | 0.1% |
| 0.20 | 1.0% | 0.3% |
| 0.80 | 3.9% | 1.0% |
再固定 g = 0.2,改变链长(也就是改变最上一段要“托住”的质点数):
| 链段数 | 各段伸长(自上而下) |
|---|---|
| 1 | 0.1% |
| 2 | 0.4% / 0.2% |
| 4 | 1.0% / 0.8% / 0.5% / 0.3% |
| 8 | 2.2% / 2.0% / 1.7% / 1.4% / 1.1% / 0.8% / 0.6% / 0.3% |
结论清楚:伸长与重力近似成正比,与“下面挂着多少质点”近似成正比,越靠上的段被拉得越长。 再把每帧系数调软(k=0.3, step=16)伸长会变成 4.1%,k=0.15 时变成 8.8%——和“松弛预算不足”直接相关。
这解释了一件对做交互的人很重要的事:这份实现没有按胡克定律积分弹簧力,而是用有限次数的位置投影逼近约束;有限迭代因此表现为有效柔度。 想让它在同样负载下更“硬”,可以增加每帧松弛轮数,或在稳定范围内提高修正系数。这也是示例必须给 frame() 留出足够松弛轮数的原因。更完整的位置约束动力学框架可参见 Müller 等人的 Position Based Dynamics。
9–10. 两个真实的坑
除零没有保护。 relax() 里的 m = normal.length2() 若两点完全重合就是 0:
1
两个完全重合的粒子做距离约束松弛 -> a.pos = (NaN, NaN) b.pos = (NaN, NaN)
一旦出现 NaN,它会顺着约束网络在一帧内传染整个复合体。这在实践中很容易踩到:用整数网格生成初值、或把某点直接设到另一点的位置上,都会产生精确重合。生成几何时加一点点抖动,比事后加判空更划算。
没有天花板。 bounds() 只夹地板与左右墙,y < 0 完全不处理:
1
2
向上运动的粒子 5 帧后 y = -382.24
画布左下方的粒子 5 帧后 pos = (156.0, -750.5)
这既是特性也是陷阱:示例正是靠它让蜘蛛从 y = -300 处“从天而降”落入网中;但如果你希望对象留在画布内,得自己补一条上界。
11. 帧率耦合:模拟时间以“帧”为单位
这是最容易被忽略、又在发布时最容易翻车的一条。引擎每帧固定施加一次重力冲量,与真实经过的毫秒数无关。测蜘蛛从 -300 落到网心高度 250(550 像素)需要多少帧:
1
84 帧 -> 60 Hz 屏:1.400 s 120 Hz 屏:0.700 s 144 Hz 屏:0.583 s
同一个页面在高刷屏上会快一倍。 想把演示发布到线上,要么接受这个(多数 canvas 玩具如此),要么在外部把 frame() 的调用次数按时间归一化(例如按 dt 累积固定 1/60 s 的虚拟帧),而不能通过改 step 来“补偿”——step 只影响约束精度,不影响物理推进量。
12–13. 角度约束:收缩率与距离约束同源,但动量不守恒
AngleConstraint.relax() 的行为可以从两个角度看。先看“它收敛得多快”,用每轮迭代后角误差的比值:
| 系数 coef | 实测每轮收缩率 | 1−4k |
|---|---|---|
| 0.025 | 0.90244 | 0.90000 |
| 0.05 | 0.80285 | 0.80000 |
| 0.10 | 0.60205 | 0.60000 |
| 0.20 | 0.20042 | 0.20000 |
同一个 1−4k。 尽管函数体里绕了四次旋转,它最终等效于与距离约束完全相同的收缩律。这是一个意外但干净的发现:只要记住这一个式子,就能同时估两种约束的行为。
再看稳定性与守恒性:
| coef | 200 次迭代后角误差 | 质心漂移 | 判定 |
|---|---|---|---|
| 0.99 | 1.097e+2 ° | 6.609e+108 px | 发散 |
| 0.50 | 4.263 ° | 5.248 px | 勉强稳定 |
| 0.30 | 1.272e-14 ° | 0.011 px | 稳定 |
| 0.025 | 3.514e-9 ° | 0.0004 px | 深度稳定 |
示例里关节系数是 1.0 / 0.9 / 0.4,除以 step = 16 后落在 0.0625 / 0.0563 / 0.025,全部处于深度稳定区。而 coef = 1(也就是把 step 拉到 1 会发生的效果)不仅发散,质心漂移还会暴涨到 $6.6\times10^{108}$——这印证了 1.4 节的怀疑:那个“转四次”的启发式不是作用量守恒的操作,系数必须留足余量。
14–15. 规模、开销与整例扫描
示例用的是 spiderweb(中心, min(w,h)/2, segments=20, depth=7):
1
2
3
蛛网:140 粒子 / 284 约束 蜘蛛:35 粒子 / 67 约束 合计 175 / 351
frame(16) 每帧松弛次数 = 5616
单帧 frame(16) 平均耗时 = 0.5481 ms(Node)
每帧只要 0.55 ms,且 1000 帧后 NaN 计数为 0——对 60 Hz(16.7 ms 预算)来说余量很大,瓶颈会在 Canvas 绘制而不是物理(这也是页面里要按应变“分桶批量描边”的原因:351 条线逐条切换 strokeStyle 会明显掉帧)。
开销对 step 基本线性:step=1 是 0.046 ms,step=16 是 0.356 ms,step=32 是 0.699 ms。把它和上面的误差表放在一起看就明白了:从 step=16 降到 step=4 能同时省掉 75% 的 CPU 并把瞬时精度提高五个数量级,代价是抗瞬时大误差的余量从 8 倍降到 2 倍。 这是这个示例里最值得重新审视的一个默认值。
最后把整例的 step 从 1 扫到 32,做一次总账:
| step | 等效系数 | 120 帧后包围半径 | 判定 |
|---|---|---|---|
| 1 | 0.9900 | 4.29e+152 px | 已崩 |
| 2 | 0.4950 | 3.56e+35 px | 已崩 |
| 4 | 0.2475 | 249.5 px | 正常 |
| 8 | 0.1237 | 268.1 px | 正常 |
| 16 | 0.0619 | 272.6 px | 示例取值 |
线性化分界落在 $k/\texttt{step} = 0.5$ 上:step = 2 给出 0.495,在线性近似里仍会缓慢收敛,但稳定余量已经极低;放进含角度约束、巨大初始误差和连续落脚扰动的整张网后,它仍然发生了非线性数值爆炸。step = 1 给出 0.99,远超阈值,于是半径变成 $10^{152}$——注意它没有变成 NaN,只是数值爆炸,所以引擎里任何“检查 NaN”的健康检查都抓不住它。这正是页面上那个“扫描 step”按钮想让你看到的东西。
16. tensor 不是“张力”,而是“不可满足程度”
最后回到 1.5 节那个 0.3。固定 step=16,静默推进 400 帧,看去掉零长度抓附约束之后的所有距离约束:
| tensor | 静长度中位 | 绝对误差 中位 / 最大 | 相对应变 中位 / 最大 | NaN |
|---|---|---|---|---|
| 1.0 | 65.01 | 0.796 / 10.52 px | 1.55% / 36% | 0 |
| 0.8 | 52.01 | 1.162 / 26.00 px | 3.15% / 60% | 0 |
| 0.6 | 39.00 | 4.699 / 60.63 px | 17.52% / 111% | 0 |
| 0.4 | 26.00 | 11.749 / 96.61 px | 56.59% / 271% | 0 |
| 0.3(示例) | 19.50 | 15.498 / 113.18 px | 96.99% / 441% | 0 |
| 0.2 | 13.00 | 19.942 / 128.35 px | 183.38% / 770% | 0 |
单调、平滑、无发散。也就是说:示例默认的 tensor = 0.3 让整张网长期运行在约 97% 的中位相对应变下——中位那条约束的实际长度是它目标长度的两倍左右。这不是数值失稳,而是一个稳定的过拉伸平衡(60 / 240 / 600 帧的统计完全一致)。
这解释了两件事:
- 为什么这张网看起来“绷得很紧、很有张力”:它确实一直绷着,而且是被永久地过度拉伸的。
- 为什么用“最大相对误差”当健康指标是错的:在 0.3 的设置下,任何固定阈值都会让整张网恒为红色。仪表盘里我把色标改成了按当帧分布自适应(中位 → 绿,97 分位 → 红),也正是因为这个。
17. 蜘蛛的身体并不是刚体
巡网时对右前腿(静长 20 / 20 / 12)采样:
| 帧 | 当前段长 | 相对静长 |
|---|---|---|
| 60 | 20.0 / 20.0 / 12.0 | 0% / 0% / 0% |
| 150 | 22.6 / 22.8 / 14.8 | +13% / +14% / +24% |
| 400 | 22.1 / 22.1 / 13.8 | +11% / +10% / +15% |
段长在 −8% 到 +24% 之间摆动;关节角按最短角计算偏离目标最多 40–65°(如果不做角度回绕处理,会虚报成 350° —— 这是个容易骗过自己的测量陷阱)。约束表也会动态增长:从构造时的 67 条变为最多 75 条,多出来的 8 条正是一条腿一条的零长度抓附约束。
画面依然可信,是因为拓扑没变。 只要连接关系正确、四肢大致在正确的方位上,人对“这是蜘蛛”的判断就不会被 15% 的段长误差推翻。这是 PBD 类方法的典型特征,也是它便宜的原因:它保证“看起来对”,而不是“解对了”。
第三部分:边界与可采信度
可采信度分层
| 结论 | 强度 | 理由 |
|---|---|---|
位置 Verlet 递推式、friction 语义、单次松弛闭式 | 强 | 逐一对照浮点输出,偏差为 0 或 6–8 位有效数字吻合 |
| 稳定性阈值 k < 0.5 | 强 | 0.49 收敛 / 0.51 爆炸的量化分界,且与闭式推导一致 |
frame(step) 的总收缩率 | 强 | 表中每个数字都与公式独立吻合 |
| 角度约束收缩率同样是 1−4k | 中偏强 | 四个系数点上均吻合到第三位小数;但只是经验规律,我没做解析推导 |
| 除零产生 NaN、无天花板、帧率耦合 | 强 | 最小可复现实验直接触发 |
| 负载相关伸长“与重力/负载成正比” | 中 | 单调性与量级可靠;我没能给出精确闭式(实测比简单模型大 30–40%) |
tensor = 0.3 导致 97% 中位应变 | 强 | 400 帧静默推进的完整分布,且 60/240/600 帧一致 |
| 0.55 ms/帧、开销随 step 线性 | 中 | Node 数字,浏览器里还要叠加 Canvas 绘制;本机是 2019 款 Intel MBP |
这个引擎里确实存在的缺陷
DistanceConstraint.relax()对length2() = 0不做保护,产生 NaN 并全网传染。bounds()没有上界,对象可以从画布顶部飞走。AngleConstraint.relax()的“转四次”不守恒质心,系数超过约 0.5 后同时发散且严重漂移。friction与groundFriction都绑定“每帧”,与刷新率耦合;frame()也不接受dt。lib/constraint.js裸用全局Vec2,脱离打包产物单独引用会在运行时报错。- 大量
for (i in array)遍历数组(拿到的是字符串下标),以及for...in的隐式继承属性风险——在示例规模下无害,但作为库被别人复用时是隐患。 - 没有任何数值健康检查,数值爆炸不产生 NaN,静默失败。
这些都不是“作者写错了”——这是一个 2013 年、300 行、面向演示的引擎,它把复杂度换成了可读性,而这一次交换是成功的。把它们列出来的意义是:如果你打算把 verlet-js 用在自己的项目里,这七条就是你的待办清单。
我对示例做了什么改动(全部披露)
页面的交互部分不是原文件,改动如下:
spiderweb()增加第 5 个参数tensor(原文件里它是函数体内的var tensor = 0.3;),目的是让页面能调它。引擎本体与其他两个原型逐行未改。- 移除广告脚本(adpacks)与 Google Analytics、Google Fonts 外链;原文件经
http://引用这些资源。 - Canvas 改为自适应尺寸并按
devicePixelRatio缩放;指针事件从鼠标扩展到触摸(原实现只监听mousedown/mousemove/mouseup)。 - 绘制改为按应变分桶批量描边 + 自适应色标;
crawl()的零长度抓附约束用独立颜色(否则它会主导“最大误差”读数——这是我在自己写的仪表盘里真实踩到的坑,第一版读数被它顶到 456%)。 - 新增验证仪表盘:实时读数、300 帧静默诊断、step 1→32 扫描。
页面验证结果
交互页在真实浏览器中完成了桌面与 390 × 844 移动视口检查:Canvas 和控制面板均可见,窄屏时布局由左右两栏切换为上下排列,页面没有横向溢出。动画持续推进时粒子与约束计数稳定为 175 / 351,控制面板能实时更新帧率、误差、应变和 NaN 计数。
「跑 300 帧诊断」按钮也经过实际点击验证:默认参数下完成静默推进,报告 NaN 节点为 0,末态包围半径有界;浏览器控制台没有运行错误。滑块、复选框、暂停、单步、重置与 step 扫描的事件处理程序还通过了脚本级语法和元素引用检查。
这不是对真实蜘蛛网的生物力学验证
本文验证的是这段程序自身的数值行为,不是“这段程序准确还原了真实蜘蛛网”。代码没有真实质量、时间单位、丝线直径、断裂、黏附、空气阻力,也没有区分径向丝和捕获螺旋丝的材料性质。
真实蜘蛛丝的应力—应变关系具有明显非线性:研究显示,丝线在受力过程中会经历软化、屈服和大应变下的重新硬化,这些性质会把损伤限制在局部,并提升整张网的结构韧性。这个示例的固定距离约束没有实现上述本构关系,参见 Cranford 等人的 Nonlinear material behaviour of spider silk yields robust webs。
蜘蛛网也不仅是承重结构,还是传递猎物、求偶者和环境扰动信息的振动网络。激光测振实验发现,纵向振动在实测频段中衰减较小,并携带更多方向信息;而本示例既不计算频谱,也没有波速和感觉器官模型,参见 Masters 与 Markl 的 Vibration signal transmission in spider orb webs。
因此,准确的结论应是:它是一张“蜘蛛网形状的位置约束网络”,能用于理解实时图形学中的积分、阻尼、预张力和迭代稳定性;它不是一张经过材料参数标定的数字蜘蛛网。
结语
这个例子里最“值钱”的东西,不是蜘蛛,也不是蛛网,而是三个可以带走的具体数字:
- k < 0.5:对这个
DistanceConstraint.relax()的线性化更新,单轮有效修正系数必须小于 0.5,误差才会衰减;这不是所有位置投影算法的通用常数。 - $(1-4k/\texttt{step})^{\texttt{step}}$:在近线性区,松弛次数不是越多越好,最强的单帧误差压缩出现在 $\texttt{step}\approx4k$ 附近;示例的 16 是“用瞬时精度换抗扰余量”的保守选择。
- 有限迭代带来有效柔度:在持续负载下,这份实现会保持一个随负载增加的稳态伸长;0.075% 与 97% 都是同一套代码的表现,差别来自网络几何和参数。
而它的美学也来自同一处:因为约束是软的、因为模拟时间是“帧”而不是“秒”、因为没有天花板,蜘蛛才会那样松松垮垮地挂在网上晃。把物理做“对”和把物理做“好看”经常是两件事,这个 300 行的引擎选择了后者,并且诚实地把选择留在了你可以直接改的参数里。
本站的另外几个交互页走了不同的路:哈密顿水母 用辛积分器把能量守恒做成可见的拖尾,复数几何级数水母 关心的是级数收敛域,奈奎斯特频率可视化 讨论的是采样定理——它们的共同点是:参数不是装饰,而是被判据约束的。这一篇也一样,只是判据换成了 0.5 这个数。