/calculators/graph3d/z = f(x, y) 的三维曲面,用的是 Canvas 2D——没有 WebGL,没有 three.js,整个渲染器是 944 行 TypeScript。这篇写它是怎么工作的,但重点不在“我实现了一个软渲染器”,而在两件被实测推翻的事:

  1. 我在上一篇工具集综述里写过“不上 WebGL 是因为瓶颈在 JS 里的表达式求值,不在光栅化”。这句话是错的,而且错得很彻底:n=88 时求值全部 7,921 个采样点约 1~3ms,画一帧 181ms。
  2. 曲面图的标准做法是画家算法——按深度把四边形从远到近排序再画。这个排序是不必要的:单值高度场在正交相机下存在一个精确的从后往前顺序,两个 if 就能拿到,不用比较,不用 O(n² log n)。

结论是一帧从 166ms 降到 59ms(f0a74fe),而且不是靠换渲染后端换来的。

先把那句话推翻

原话的逻辑听起来无懈可击:曲面上每个格点都要跑一次用户输入的表达式,f() 是解释执行的闭包树,所以贵的一定是它;GPU 再快也不会让 f() 变快。

问题是我从来没量过。量法很简单:改公式走的是“采样 + 着色 + 绘制”整条路,旋转视角走的只有“绘制”,两者相减就是采样的成本。

公式(n=88) 旋转一帧 改公式整条路 差 = 采样
x + y 181.2ms 180.7ms ≈0(噪声内)
sin(x)*cos(y) 166.3ms 178.9ms 12.6ms
sin(sqrt(x^2+y^2)) 174.2ms 195.4ms 21.2ms
8 项三角和 164.6ms 258.4ms 93.8ms

7,921 次 x + y 的求值成本落在测量噪声里,同一时间绘制花掉 181ms。渲染是求值的 8~180 倍,方向和我写的完全相反。

唯一让原话站得住的是最后一行:把公式堆到八项三角和,求值终于超过了绘制。也就是说那句话描述的是一个存在但非默认、非典型的场景——七个内置示例公式没有一个属于它。

测量口径

下面所有数字都来自同一台机器、同一个 Playwright + Chromium(项目里已缓存的 chromium-1223),视口 1280×900,devicePixelRatio 强制为 1,每项取 11~25 次的中位数,事件同步派发以便 performance.now() 能框住整个 handler。改动前后是配对测量:同一个 harness、同一次会话,中间只做了 git stash 加一次重新构建。

两个必须说清的前提:headless Chromium 很可能走 CPU 光栅化,所以绝对毫秒数是机器相关的,别拿去和自己的笔记本比;但这篇的主要发现是 CSS 颜色字符串解析的开销,那部分在 CPU 侧,有没有 GPU 都一样要付。

正交投影:一次变换,三个数

世界坐标先归一化。x 和 y 共用一个尺度,所以定义域的长宽比不会被拉歪;z 的单位和 x、y 无关(sin(x) 的值域是 ±1,x^2 - y^2 的是 ±50),所以 z 按自己的值域单独归一化,再压一个 Z_STRETCH = 0.75 的系数,免得曲面竖成一根针:

u=xxcs,v=yycs,s=max(xmaxxmin2, ymaxymin2)u = \frac{x - x_c}{s}, \qquad v = \frac{y - y_c}{s}, \qquad s = \max\left(\frac{x_{\max} - x_{\min}}{2},\ \frac{y_{\max} - y_{\min}}{2}\right) w=clamp(z, zlo, zhi)(zlo+zhi)/2(zhizlo)/20.75w = \frac{\mathrm{clamp}(z,\ z_{lo},\ z_{hi}) - (z_{lo} + z_{hi})/2}{(z_{hi} - z_{lo})/2} \cdot 0.75

相机只有两个自由度:方位角 θ(yaw)和仰角 φ(pitch)。投影是四行算术:

t=usinθ+vcosθt = u\sin\theta + v\cos\theta px=ox+(ucosθvsinθ)σ,py=oy(tsinφ+wcosφ)σp_x = o_x + (u\cos\theta - v\sin\theta)\,\sigma, \qquad p_y = o_y - (t\sin\varphi + w\cos\varphi)\,\sigma d=tcosφwsinφd = t\cos\varphi - w\sin\varphi

t 是世界点在“朝向相机”那条水平轴上的投影,σ=min(W,H)0.36zoom\sigma = \min(W, H) \cdot 0.36 \cdot \text{zoom} 是像素尺度,d 是深度——离相机越远越大。

选正交而不是透视,省掉的不只是一个除法:没有近裁剪面要处理,没有 w 分量要归一化,缩放就是乘一个标量。更关键的是视线方向在整个画布上是同一个方向,这是下一节那个精确顺序能成立的前提。

注意 pxp_x 里没有 w——高度不影响水平位置。这条稍后放坐标轴时会用到。

画家算法不需要排序

没有深度缓冲,就得让远的先画、近的后画盖上去。原来的实现是教科书写法:算出每个四边形四个角深度的平均值,按它排序,然后依次画。7,744 个格子,每帧一次比较排序,实测 7.6ms。

这个排序可以整个删掉。

视线方向

先求视线方向。它是那个“移动时 pxp_xpyp_y 不变、dd 增大”的世界方向 r=(ru,rv,rw)\mathbf{r} = (r_u, r_v, r_w)pxp_x 不变要求 rucosθrvsinθ=0r_u\cos\theta - r_v\sin\theta = 0,即 (ru,rv)(sinθ,cosθ)(r_u, r_v) \parallel (\sin\theta, \cos\theta)pyp_y 不变再定出 rwr_w

r=(sinθcosφ, cosθcosφ, sinφ)\mathbf{r} = (\sin\theta\cos\varphi,\ \cos\theta\cos\varphi,\ -\sin\varphi)

代进去验一下:r\mathbf{r}dd 的梯度点积等于 cos2φ+sin2φ=1>0\cos^2\varphi + \sin^2\varphi = 1 > 0,方向对。

于是视线在 (u, v) 平面上的投影是 (sinθ,cosθ)cosφ(\sin\theta, \cos\theta)\cos\varphi。仰角限制在 ±88°(PITCH_LIMIT),所以 cosφ>0\cos\varphi > 0 恒成立——不管相机在上方还是下方:

du=sinθcosφ,dv=cosθcosφ\frac{\partial d}{\partial u} = \sin\theta\cos\varphi, \qquad \frac{\partial d}{\partial v} = \cos\theta\cos\varphi

沿视线前进时,u 单调(sinθ>0\sin\theta > 0 时递增),v 也单调(cosθ>0\cos\theta > 0 时递增)。

为什么格点顺序是精确的

关键在于曲面在每个格子上单值——z = f(x, y) 每个 (x, y) 只有一个高度。于是一条视线穿过曲面时,它遇到四边形的先后顺序,就等于它进入那些格子所在的先后顺序。而上一节已经证明:沿视线走,格子下标 i 单调、j 单调。

所以:如果四边形 B 比 A 远(同一条视线上被后碰到),那么在“前进方向”的坐标系里 iBiAi_B \ge i_AjBjAj_B \ge j_A。两个在屏幕上重叠的格子在 (i, j) 的积序下必定可比;反过来,i 大而 j 小这种不可比的格子对,永远不可能落在同一条视线上,画谁先谁后无所谓。

而嵌套循环的字典序是积序的一个线性扩展。所以从远端那一角往里走 i、j,就是一个精确的从后往前顺序:

// Depth grows with u where sin(yaw) > 0 and with v where cos(yaw) > 0, so
// the far corner of the lattice is the high end of each of those axes.
const iFrom = sy > 0 ? N - 1 : 0;
const iStep = sy > 0 ? -1 : 1;
const jFrom = cy > 0 ? N - 1 : 0;
const jStep = cy > 0 ? -1 : 1;

四个变量换掉一次 O(n² log n) 排序,而且结果不是近似——是精确解。sinθ=0\sin\theta = 0 时视线在 u 方向根本不前进,i 的顺序任意,代码落到 else 分支也无所谓;φ = ±90° 会退化(每条视线只碰一个格子,顺序无意义),而 88° 的限位本来就到不了那里。

那原来的排序错在哪,又为什么看不出来

用四角深度的平均值代表一个四边形,只在四边形本身深度跨度很小的时候成立。一面近乎垂直的墙(1/(x^2+y^2) 被裁剪的尖峰的侧面)跨越很大的深度区间,它的平均值代表不了自己,和邻居比较就可能比错。

为了确认这不是空谈,我把改动前后的画布逐像素 A/B 了 21 组(视角 × 公式 × 样式)。几何和颜色算术是逐位相同的——着色代码是从原来的帧循环里原样搬出去的——所以差异 100% 来自绘制顺序:

差异最小的是 front-cliff:14 个像素,0.004%。最大的是 top-ripple:29,074 个像素,占整张画布 7.7%、占已绘制像素 19.7%;wire-ripple 占已绘制像素 25.1%。单通道最大差整体落在 4~152 之间,其中 152 和 139 分别来自 polespike-mesh

数字看着吓人,但“差异像素”里绝大多数是共享边上哪个格子的 0.7px 接缝最后落笔:除尖峰那两例,其余用例的单通道最大差都在几十以内,差异图上呈现为沿着每条格线的细曲线,肉眼不可见。我另外算了个斑点指标(一个像素和上下左右四个邻居全都不一致的数量,孤立的错格子会让它涨),改动前后基本不动:默认视角 754 → 748,涟漪 414 → 424,俯视涟漪 285 → 285。

真正可见的分歧只出现在 polespike-mesh 上,最大差 152,位置正是裁剪尖峰那几面近垂直的墙——和上面的分析对得上。

所以诚实的说法是:**旧顺序是近似的,但在这些曲面上并没有明显画错。**为什么一个没人验证过的近似能撑这么久?看深度公式 d=tcosφwsinφd = t\cos\varphi - w\sin\varphi:相机在上方时两项同向——沿视线走,xy 距离变远(t 增大)、高度降低(w 减小),两项都让 d 增大。也就是“更高、且 xy 更近”恰好就是“更近”,而四角平均值已经把这个信息编码进去了。换掉它的理由不是它画错了,而是精确解免费。

一帧的钱:把 canvas 逐项拆开

排序只有 7.6ms,那 181ms 花在哪?我写了八个变体,每个都画同样的 7,744 个四边形,逐层剥掉:

变体 7,744 格
A 当前写法:每格 fillStyle = 'rgb(…)'strokeStyle = ctx.fillStyle 147.2ms
B 同一个字符串同时喂给 fill 和 stroke(不读 getter) 88.5ms
D 颜色字符串预先建好,循环里只赋值 57.9ms
E 颜色恒定不变,仍然 fill + stroke 24.0ms
F 颜色恒定,只 fill 17.6ms
G 只建路径,不 fill 不 stroke 2.7ms
H 全部四边形并进一条路径,最后 fill 一次 659.4ms

读法:真正的光栅化(F − G)值 15ms,加上接缝描边(E − G)值 21ms。而 A − E = 123ms 花在颜色状态上,占整帧的 84%。1,936 个格子下同一组测量的两端是 A 36.1ms、H 40.4ms,比例一致。

ctx.fillStyle = 'rgb(120, 180, 90)' 要做的事,是把一个字符串按 CSS 颜色语法解析成内部颜色对象。单独测:新建模板字符串再赋值,7,744 次 22.6ms;用预先建好的字符串赋值 16.4ms;只建字符串不赋值 2.5ms。一次赋值大约 2μs,听起来微不足道——乘 7,744 就是一帧的大头。

ctx.strokeStyle = ctx.fillStyle 是一次序列化往返

A 和 B 只差一处:A 写 ctx.strokeStyle = ctx.fillStyle,B 把同一个字符串变量赋给两边。差 58.7ms,将近整帧的 40%

原因是 ctx.fillStyle 的 getter 不返回你刚才存进去的那个字符串,它按 CSS 序列化规则重新构造一个'rgb(120, 180, 90)''#78b45a' 这些写法都会被归一成同一种形式)。于是每个四边形都白做一次“解析 → 存储 → 序列化 → 再解析”的往返。

有意思的是单独测这一句只有 11.2ms——因为微基准里颜色是恒定的,引擎可以缓存上次的序列化结果。真实循环里颜色每格都变,快路径失效,代价才现出来。这也是微基准会骗人的一个具体例子:孤立测量的是最好情况,不是循环里的情况。

把所有四边形并成一条路径,慢 4.5 倍

变体 H 是直觉上的优化:既然 7,744 次 fill() 很贵,那就把所有四边形 moveTo/lineTo 进同一条路径,最后 fill 一次。结果 659.4ms——比逐个填充慢 4.5 倍

原因是 fill() 默认用非零环绕规则,它必须在整条复合路径的所有子路径之间求交、判断环绕数。7,744 个子路径、约 3 万条线段,这个求交是超线性的。批量化在这里不但没用,还是最差的写法。

(顺带说,这条路即使能跑通也用不上:每个格子颜色不同,一次 fill 只能给一种颜色。)

改完

三处改动,都在 JS 里:

  1. **着色搬出帧循环。**格子颜色和顶点高度只依赖高度场和世界坐标系里固定的光向,与相机无关,所以从每帧改成 resample() 时算一次,旋转直接复用那 7,744 个字符串。
  2. 删掉排序,换成上一节那四个变量。
  3. 邻格颜色相同就跳过赋值,并且 stroke 用同一个字符串而不是读 getter。第三条最省事也最值钱:相邻格子的 8 位颜色经常四舍五入到同一个三元组,跳过重复是白捡的。

配对实测(旋转一帧,中位数):

公式 n=44 前 → 后 n=88 前 → 后
x + y 43.3 → 17.8ms 181.2 → 71.3ms
sin(x)*cos(y) 39.8 → 15.3ms 166.3 → 59.1ms
sin(sqrt(x^2+y^2)) 40.0 → 14.7ms 174.2 → 57.0ms

样式 mesh 130.6 → 64.3ms,wire 95.1 → 47.2ms。改公式那条整路(采样 + 着色 + 绘制)n=88 也从 178.9ms 降到 109.0ms——搬进来的着色 pass 比省下的排序和颜色解析便宜。

着色 pass 本身的成本可以交叉验证:它只和格子数有关,与公式无关。三个良性公式给出的隐含值是 35.9 / 37.3 / 38.8ms,彼此吻合;八项三角和那一行噪声大得多(它自己的编译和求值占了主导),不用它来估。

着色为什么可以搬出帧循环

这条边界值得说清楚,因为搬错了就是 bug:光向固定在世界坐标系里,不是相机坐标系里。

/** Fixed world-space light (upper front left). World-space rather than
 *  camera-space so that rotating the plot actually reveals its shape. */
const LIGHT = /* 归一化的 */ [-0.42, -0.58, 0.7];

一个格子的法向来自它两条对角线的叉积,只依赖四个角的高度;法向和 LIGHT 的点积因此也只依赖高度场。旋转视角时,光源跟着世界一起不动,所以背光面会转到向光面——曲面的形状因此在旋转中被“摸”出来,这正是想要的效果。如果把光向固定在相机坐标系里(headlight),旋转时明暗完全不变,曲面看起来像一张贴纸。

代价是所有着色量都与相机无关,可以缓存。缓存下来的是:

  • w:每个格点的归一化高度((N+1)² 个 double)
  • ok:四个角是否都有限(每格 1 字节)
  • tone:颜色渐变上的位置(每格一个 double)
  • fillrgb(…) 字符串(每格一个)

tone 单独留着,是因为线框样式要的是不带明暗的颜色(线条只表达高度),需要用同一个 tone 重新建一批 rgba(…, 0.6)。这批字符串按需生成——绝大多数访客从不切换样式,没必要每次采样都白建 7,744 个。

明暗用 0.52 + 0.48 * Math.abs(dot):取绝对值是双面光照,否则背面全黑;0.52 的底让最暗的面仍然能看出颜色。

留在帧循环里的只剩投影:7,921 个格点各算一次 pxpy,写进两个 Float64Array,然后 7,744 个格子各自取四个下标。变体 G 说了,这部分连带建路径一共 2.7ms。

极点:1–99 百分位裁剪

1/(x^2+y^2) 在原点附近能到 10⁶ 量级,而曲面其余部分都在 0.1 以下。按真实值域归一化的结果是:一根看不见宽度的针,和一张纯深紫色的平板。

所以当尾部极端时改用分位数:

if (vals.length > 20) {
	vals.sort((a, b) => a - b);
	const lo = q(0.01), hi = q(0.99);
	if (hi > lo && zHi - zLo > (hi - lo) * 4) { zLo = lo; zHi = hi; clipped = true; }
}

触发条件是真实值域超过 1–99 百分位跨度的 4 倍,不是“存在极点”——良性曲面的值域一个字节都不动。触发之后 toW 里的 clamp 让尖峰所有超界的格点都贴到 Z_STRETCH,于是尖峰被画成一个平顶,而不是被裁掉留个洞。

这件事必须告诉用户,否则读数会骗人:读数行会写出 range clipped to the 1st–99th percentile (the surface has a spike),同时极值仍然报真实值——你看到的是被裁剪的形状,但知道真正的最大值是多少、在哪个 (x, y)。

坐标轴放在哪不会被挡住

软渲染器没有深度缓冲,坐标轴文字画在曲面之后就一定盖在上面,画在之前就可能被曲面糊掉。x、y 轴好办:比较两条对边的深度,把刻度放在近的那一侧,标签再往盒子外偏 14px。

z 轴用了投影的一个性质。回头看 px=ox+(ucosθvsinθ)σp_x = o_x + (u\cos\theta - v\sin\theta)\sigma它不含 w。所以盒子的一条竖棱投影出来是一条竖直线段,而整张图里 px 最小的位置必然落在某条竖棱上——把 z 的刻度数字放在那条棱再往左 8px,就永远不会被曲面覆盖。四个角里选 px 最小的那个,两个角一样靠左(正面直视)时取更远的那个:

const better = q.px < bestQ.px - 0.5
	|| (Math.abs(q.px - bestQ.px) <= 0.5 && q.depth > bestQ.depth);

还有一条:被投影压扁的轴不画刻度。俯视时 z 轴投影长度趋近 0,正视时 y 轴同理,此时一堆数字会叠在同一个点上。所以任何轴在屏幕上短于 AXIS_MIN_PX = 42 就整条留空——宁可少信息,不要一团糊。

文字本身还带一圈背景色描边(先 strokeText 背景色、再 fillText 前景色),万一真的落在曲面上也读得出来。

只有四个角全部有限的格子才画。ln(x*y) 在两个象限里没有定义,1/(x^2+y^2) 在原点是 Infinitysqrt(-1)NaN——这些格点不参与,边界上的格子自然缺掉,曲面上留下一个干净的洞。

替代方案是把非有限值当 0 或者当边界值,那会画出一片根本不存在的平地。留洞是唯一诚实的画法。

这套方案什么时候不够用

三个真实的边界,写在这里免得下次又要重新发现:

  1. **透视相机会打破那个精确顺序。**推导用到“视线方向全画布唯一”,这是正交独有的。透视下每个像素的视线方向都不同,不存在一个全局顺序,只能回到排序或者上 z-buffer。
  2. **多值曲面同样打破它。**参数曲面(比如球面、环面)在同一个 (u, v) 上有多个高度,“曲面在每个格子上单值”这个前提没了,格点顺序不再是精确解。
  3. **求值仍然会成为瓶颈。**八项三角和在 n=88 下整条路 240.8ms,其中渲染只占 65.5ms,剩下的是编译加 7,921 次求值。渲染器已经不是问题了,那条路要快只能去动表达式引擎——把 call 那一步每次调用分配的参数数组去掉之类。

至于 WebGL:一帧的钱 84% 花在 CSS 颜色字符串解析上,而这是 CPU 侧的开销。换渲染后端解决不了一个字符串解析问题,在 JS 里解决了,两倍半到三倍。什么时候该上 WebGL?当剩下的 15~21ms 真正的光栅化成为瓶颈的时候——按现在的分辨率上限,还差得远。

改这种代码的安全网

最后说一句和渲染无关但更重要的:这次动的是一个输出是像素的函数,绝大多数错误不会抛异常,只会画得不对。

能放心重写画循环,是因为这个页面有 11 个 E2E 用例,其中一部分把画布 getImageData 读回来数已绘制像素(错误公式必须画出 0 个像素),另一部分比较 toDataURL() 的快照(方向键转视角后按 0 复位,必须逐位还原初始快照)。所以“改完 91 个用例全绿”这句话不只是流程,它是这次改动唯一的正确性证据——同一套机制还让我能把改动前后 21 组渲染逐像素 diff 出来,从而把“顺序变了但没画错”从猜测变成结论。

去转一转这个曲面 →