文章

蛛网、蜘蛛与位置约束:拆解并数值验证 subprotocol/verlet-js

用十余组可复现的数值实验把 subprotocol/verlet-js 的 Spiderweb 示例拆开:位置 Verlet 的确切递推、距离约束的误差放大律与 0.5 稳定性阈值、角度约束的收缩率、以及这个示例为什么必须写 frame(16)。附一个把引擎搬进网页的实时验证实验台。

蛛网、蜘蛛与位置约束:拆解并数值验证 subprotocol/verlet-js

2013 年,Sub Protocol 发布了一个 300 行的 JavaScript 物理引擎 verlet-js。它只做三件事:质点、距离约束、角度约束。用这三样东西,它做出了会自己爬网的蜘蛛——例子里的蜘蛛起初非常擅长独自玩扭扭乐(Twister),但不太会爬行,作者的解法是按蜘蛛的原点与轴向切出象限,每条腿认领一个象限,然后在自己的象限里找落脚点。

这个例子值得读的原因不是效果,而是它小到可以一眼看完:没有空间划分、没有矩阵、没有质量、没有时间步,全部物理只发生在两个函数里——一个做积分,一个做约束修正。也正因为小,它的每一条行为都可以被算准,而不是“看着像”。

这篇文章做两件事:

  1. 逐行读出它的确切数学(不是“大致是 Verlet 积分”,而是写出递推式);
  2. 把引擎原样搬进 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 就是

\[n = s\cdot\frac{d^2 - \lvert s\rvert^2}{\lvert s\rvert^2}\cdot k\]

两个端点各移动 $\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) 是“会爬”的全部秘密:

  1. 用胸腔到头的方向算出朝向 $\theta$,构造两个边界向量 $\theta$ 与 $\theta + \pi/2$,再配合 flag1(左右)与 flag2(前后)把平面切成四个象限;
  2. 在蛛网里遍历落点,只保留落在本腿象限、且距胸腔 35–100 像素、且没被其他腿占用的点;
  3. 打乱候选,取第一个,给这条腿加一条目标长度为 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
2friction 的语义v100/v1 = 0.99^99 精确吻合每帧乘性阻尼,非物理黏滞
3地板与地面摩擦停在地板 h-1;落地后每帧 ×0.8groundFriction 与帧率绑定
4距离约束误差放大律实测与闭式 8 位小数吻合单次松弛误差 ×(1−4k)
5稳定性阈值0.49 收敛 / 0.51 爆炸阈值严格在 k = 0.5
6frame(step) 的真实语义近线性区符合 (1−4k/step)^stepstep 不是时间步,是松弛次数
7“刚性”杆有多刚100 px 杆稳态误差 0.075 px0.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) 的必要性
16tensor 的真实作用中位相对应变 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.050.008009900.008009900.80
0.200.002039600.002039600.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.402.000e+01.421e-14收敛
0.492.000e+05.633e-4收敛(很慢)
0.502.000e+02.000e+0纹丝不动
0.512.000e+02.068e+2发散
0.992.000e+01.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() 的误差总收缩率近似为

\[\left(1 - \frac{4k}{\texttt{step}}\right)^{\texttt{step}}\]

实测($k = 0.99$,初始误差 2 px):

step等效系数一帧后残余误差说明
10.99005.842e+0比初始还大,已发散
20.49501.920e+0仍在阈值边缘
40.24754.019e-8与 $(1-0.99)^4$ 吻合,最优
80.12378.627e-3与 $(0.5051)^8$ 吻合
160.06192.142e-2示例取值
320.03092.951e-2比 16 还差

这张表有两个反直觉之处,两个都被公式解释了:

  1. 收缩率不是 step 的单调函数。 它形如 $c^{\,s}$,而 $c = 1-4k/s$ 随 $s$ 增大而趋近 1,两者竞争的最小值出现在 $4k/s \to 1$ 附近,也就是 $s \approx 4k$。示例里 $k \approx 1$,所以最优在 step = 4 附近,残余误差能做到 $10^{-8}$ 量级。
  2. 示例的 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.050.2%0.1%
0.201.0%0.3%
0.803.9%1.0%

再固定 g = 0.2,改变链长(也就是改变最上一段要“托住”的质点数):

链段数各段伸长(自上而下)
10.1%
20.4% / 0.2%
41.0% / 0.8% / 0.5% / 0.3%
82.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.0250.902440.90000
0.050.802850.80000
0.100.602050.60000
0.200.200420.20000

同一个 1−4k。 尽管函数体里绕了四次旋转,它最终等效于与距离约束完全相同的收缩律。这是一个意外但干净的发现:只要记住这一个式子,就能同时估两种约束的行为。

再看稳定性与守恒性:

coef200 次迭代后角误差质心漂移判定
0.991.097e+2 °6.609e+108 px发散
0.504.263 °5.248 px勉强稳定
0.301.272e-14 °0.011 px稳定
0.0253.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 帧后包围半径判定
10.99004.29e+152 px已崩
20.49503.56e+35 px已崩
40.2475249.5 px正常
80.1237268.1 px正常
160.0619272.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.065.010.796 / 10.52 px1.55% / 36%0
0.852.011.162 / 26.00 px3.15% / 60%0
0.639.004.699 / 60.63 px17.52% / 111%0
0.426.0011.749 / 96.61 px56.59% / 271%0
0.3(示例)19.5015.498 / 113.18 px96.99% / 441%0
0.213.0019.942 / 128.35 px183.38% / 770%0

单调、平滑、无发散。也就是说:示例默认的 tensor = 0.3 让整张网长期运行在约 97% 的中位相对应变下——中位那条约束的实际长度是它目标长度的两倍左右。这不是数值失稳,而是一个稳定的过拉伸平衡(60 / 240 / 600 帧的统计完全一致)。

这解释了两件事:

  • 为什么这张网看起来“绷得很紧、很有张力”:它确实一直绷着,而且是被永久地过度拉伸的。
  • 为什么用“最大相对误差”当健康指标是错的:在 0.3 的设置下,任何固定阈值都会让整张网恒为红色。仪表盘里我把色标改成了按当帧分布自适应(中位 → 绿,97 分位 → 红),也正是因为这个。

17. 蜘蛛的身体并不是刚体

巡网时对右前腿(静长 20 / 20 / 12)采样:

帧当前段长相对静长
6020.0 / 20.0 / 12.00% / 0% / 0%
15022.6 / 22.8 / 14.8+13% / +14% / +24%
40022.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

这个引擎里确实存在的缺陷

  1. DistanceConstraint.relax() 对 length2() = 0 不做保护,产生 NaN 并全网传染。
  2. bounds() 没有上界,对象可以从画布顶部飞走。
  3. AngleConstraint.relax() 的“转四次”不守恒质心,系数超过约 0.5 后同时发散且严重漂移。
  4. friction 与 groundFriction 都绑定“每帧”,与刷新率耦合;frame() 也不接受 dt。
  5. lib/constraint.js 裸用全局 Vec2,脱离打包产物单独引用会在运行时报错。
  6. 大量 for (i in array) 遍历数组(拿到的是字符串下标),以及 for...in 的隐式继承属性风险——在示例规模下无害,但作为库被别人复用时是隐患。
  7. 没有任何数值健康检查,数值爆炸不产生 NaN,静默失败。

这些都不是“作者写错了”——这是一个 2013 年、300 行、面向演示的引擎,它把复杂度换成了可读性,而这一次交换是成功的。把它们列出来的意义是:如果你打算把 verlet-js 用在自己的项目里,这七条就是你的待办清单。

我对示例做了什么改动(全部披露)

页面的交互部分不是原文件,改动如下:

  1. spiderweb() 增加第 5 个参数 tensor(原文件里它是函数体内的 var tensor = 0.3;),目的是让页面能调它。引擎本体与其他两个原型逐行未改。
  2. 移除广告脚本(adpacks)与 Google Analytics、Google Fonts 外链;原文件经 http:// 引用这些资源。
  3. Canvas 改为自适应尺寸并按 devicePixelRatio 缩放;指针事件从鼠标扩展到触摸(原实现只监听 mousedown/mousemove/mouseup)。
  4. 绘制改为按应变分桶批量描边 + 自适应色标;crawl() 的零长度抓附约束用独立颜色(否则它会主导“最大误差”读数——这是我在自己写的仪表盘里真实踩到的坑,第一版读数被它顶到 456%)。
  5. 新增验证仪表盘:实时读数、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。

因此,准确的结论应是:它是一张“蜘蛛网形状的位置约束网络”,能用于理解实时图形学中的积分、阻尼、预张力和迭代稳定性;它不是一张经过材料参数标定的数字蜘蛛网。


结语

这个例子里最“值钱”的东西,不是蜘蛛,也不是蛛网,而是三个可以带走的具体数字:

  1. k < 0.5:对这个 DistanceConstraint.relax() 的线性化更新,单轮有效修正系数必须小于 0.5,误差才会衰减;这不是所有位置投影算法的通用常数。
  2. $(1-4k/\texttt{step})^{\texttt{step}}$:在近线性区,松弛次数不是越多越好,最强的单帧误差压缩出现在 $\texttt{step}\approx4k$ 附近;示例的 16 是“用瞬时精度换抗扰余量”的保守选择。
  3. 有限迭代带来有效柔度:在持续负载下,这份实现会保持一个随负载增加的稳态伸长;0.075% 与 97% 都是同一套代码的表现,差别来自网络几何和参数。

而它的美学也来自同一处:因为约束是软的、因为模拟时间是“帧”而不是“秒”、因为没有天花板,蜘蛛才会那样松松垮垮地挂在网上晃。把物理做“对”和把物理做“好看”经常是两件事,这个 300 行的引擎选择了后者,并且诚实地把选择留在了你可以直接改的参数里。

本站的另外几个交互页走了不同的路:哈密顿水母 用辛积分器把能量守恒做成可见的拖尾,复数几何级数水母 关心的是级数收敛域,奈奎斯特频率可视化 讨论的是采样定理——它们的共同点是:参数不是装饰,而是被判据约束的。这一篇也一样,只是判据换成了 0.5 这个数。

本文由作者按照 CC BY 4.0 进行授权