← 图形原理 · 交互课 课题十 · 几何处理 · 第 1 课

曲线:几个控制点怎么夹出一条光滑的线

全课题骨架:几何不是"画"出来的,是控制点 + 一条可以反复施加的规则算出来的 —— 而那条规则只做一件事:在两点之间取一点。
前九个课题的输入都是"已经离好的东西"(顶点、像素、瓦片、网格、样例图)。这个课题退到更前面一步: 那些顶点和网格是从哪儿来的? —— 造几何这件事,在本仓一直是个没拆过的黑盒。
它出人意料地简单:整条路只有一条规则,就是"在两点之间取一点"。 把它沿着一排控制点重复 n 次,得到一条曲线(本课); 换到一个网格的邻域上重复,得到细分(第 2 课);在两个方向各重复一遍,得到一张曲面(第 3 课)。
四段路:两个点之间一个数 t(20.1)→ 三个点:规则用两次(20.2)→ 一般的 n(20.3)→ 两条接起来时光滑的条件(20.4)。 保真度:示意 · 等价重现 —— 曲线求值、金字塔、Bernstein 形式、分割、升阶、端点切线 都是纯算术(页面现场跑的数与离线定稿 q201.mjs 逐位相同,十条判据都能离线复算); 但画面是本页自己的二维软件光栅(逐像素写帧缓冲再搬上画布),不是硬件顶点着色器产出。 ★ 真机上的常见做法是把 lerp / pyramid 放进顶点着色器、每个顶点算一个 t (本站课题二第 1 课演示过那条管线);本页在 CPU 上算同一份顶点数组,但不声称两者逐位相同。

分段:
控制点数: ← 20.3 的旋钮(其余段固定为 3)
参数 t: t = 0.50
第二段的接缝: ← 20.4 的旋钮(只挪 Q₁ 一个点)

(脚本启动中…)

定点观察:(脚本启动中…)

本帧的读数

(脚本启动中…)

20.1 段 · 两个点之间取一个点

本段只增加一样东西:参数 t(画面里是两个控制点与它们之间那一点;其余控件与各段一致)。 保真度:示意 · 等价重现。造几何最小的情况就是两个点 —— 一条线段。 可"线段上的一点"这件事已经在用那条规则了:把它写成两个端点的一个加权平均, 权重是 t 与 (1−t)。t 不是"第几个点",它是一个比例。

输入 控制点 A = (140, 380) 控制点 B = (820, 120) 参数 t 0 ≤ t ≤ 1 读数 P(0.5) 五档见表① 端点 t=0 → A t=1 → B 线上任一点 都是凸组合 ① 在两点之间取一点(本页真跑的 lerp) function lerp(a, b, t) { // 规则本身 return { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) }; } // 一条线最小的情况,就是两个点 // t = 0 落在 A,t = 1 落在 B // 中间每一档都是两点的一个凸组合 // 表① 那一列的闭式故意换一种等价写法: // P = (1 - t) * A + t * B // 这五档 t 都是二进小数 ⇒ 差恰为 0 // 扫到 721 档才见 1e−13(判据③) // 两个点没有"弯"可言:t 只是一个比例 // 想要弯,就得再加一个点 ⇒ 见 20.2 t = 0.5 时 P = (480, 250) |PA| = 364.0 |PB| = 364.0 比值 1 : 1 五档见表① 端点 t = 0 → A t = 1 → B 线上任一点 都是 t 的像
定点观察:把「参数 t」从 0 拖到 1 → 看点落在两个控制点之间的哪一段、以及读数里的 |PA| : |PB| → 那个比值恰好是 t : (1−t):t 不是"序号",是一个比例。 这也是"加权平均"这四个字第一次出现的地方 —— 后面三段全都只是它的重复。
表① 两个点之间取一个点(A = (140, 380)、B = (820, 120))。闭式一列故意写成另一种等价写法 (1−t)·A + t·B —— 页面真跑的是 lerp 那一版的 A + t·(B−A):两者数学上相同、浮点上不保证逐位相同。本表这五档的 t 都是二进小数,两种写法都算得精确 ⇒ 差恰好是 0;把 t 连续扫到 721 档就会看到 1e−13 量级(判据③ 报的就是它)。最后一列是"到两端距离之比"
t闭式 (1−t)·A + t·B实测(lerp)差 (px)|PA| : |PB|

算式:lerp(a, b, t) = a + t·(b − a),两个分量各算一次。 把 t = 0 与 1 代进去就回到 A 与 B(这一条叫端点插值,是后面所有段的共同底线); 把 t = 0.5 代进去得到 (A + B)/2。
表① 的"闭式"一列为什么要换一种写法:a + t·(b − a) 与 (1−t)·a + t·b 是同一个量的两种等价写法, 数学上完全相同,但浮点上并不保证逐位相同(乘的顺序不同、舍入点就不同)。这一列如果照抄成 a + t·(b − a),差会恒为 0 —— 那不是验证,是复读。
换过来之后"差"那一列报的才是真东西,而它的读数分两种情形:本表这五档的 t 都是二进小数 (0 / 0.25 / 0.5 / 0.75 / 1),两种写法都算得精确 ⇒ 差恰好是 0; 而把 t 连续扫到 721 档(判据③),就会出现 1e−13 量级的差 —— 那正好是一个坐标(几百 px)的末位。 "差 0"与"差 1e−13"在这里都对,取决于 t 取在哪。
注意 t 的含义:它是"从 A 走到 B 的这个比例",所以 |PA| : |PB| = t : (1−t) —— 这正是"加权平均"里两个权重的来处。但两个点没有"弯"可言:不管 t 怎么取,点永远落在那条线段上。 想要它弯,就得允许"多一个点、再多用一层规则" —— 那是 20.2。

20.2 段 · 三个控制点:把这条规则用两次

本段只增加一样东西:控制点数 2 → 3(规则一个字都不换,只是多施加一层;其余控件一致)。 保真度:示意 · 等价重现。上一段只解决了"两个点之间的比例",可两个点画出来的永远是直线段。 想让它弯,最省事的补法是再加一个控制点,然后问一句:三个点怎么用同一条规则? 答案是把规则用两次:先在 (A,B) 与 (B,C) 上各取一点,再在这两点之间取一点。 两次之后得到的不再是直线 —— 而且它只经过 A 与 C。

输入 三个控制点 A = (120, 420) B = (480, 80) C = (840, 420) 参数 t 同一个 t 用两层 读数 两层中间点 五档见表② 反直觉处 曲线不过 B B 只是拉力 端点仍过 A、C ② 同一条规则,重复施加(本页真跑的 pyramid) function pyramid(P, t) { // P 是控制点数组 let L = P.map((p) => ({ x: p.x, y: p.y })); const rows = [L]; // rows[k] = 第 k 层 while (L.length > 1) { const N = []; for (let i = 0; i < L.length - 1; i++) N.push(lerp(L[i], L[i + 1], t)); rows.push(N); L = N; // 换到下一层 } return { val: L[0], rows: rows }; // 只剩一个点 } // 三个点 ⇒ 先用两次、再用一次 ⇒ 金字塔 3 行 // 最后那一行只剩一个点,它就是曲线上的点 // 闭式:把两层摊开就是 Bernstein 二次式 // (1-t)²A + 2t(1-t)B + t²C t = 0.5 时 P = (480, 250) 权重 .25 / .50 / .25 和 = 1 B 到曲线 最近 170.00 px (在 t=0.5 处) 所以曲线 不经过 B 见判据②
定点观察:先拖「参数 t」走一遍,再单独拖中间那个控制点 B → 看画面里两层中间线段怎么塌缩成曲线上那一点,以及读数里「B 到曲线的最近距离」 → t = 0.5 时曲线离 B 最近,也还有 170.00 px:曲线只经过首末控制点, 中间那些点只是"拉力" —— 这类曲线叫逼近(approximating)而不是插值。
表② 三个控制点、两层插值(A = (120, 420)、B = (480, 80)、C = (840, 420))。闭式写成展开后的等价式 (1−t)²A + 2t(1−t)B + t²C,交叉验证页面真跑的两层金字塔
t闭式 (1−t)²A + 2t(1−t)B + t²C实测(两层金字塔)差 (px)|P(t) − B| (px)

算式:把规则用两次,摊开就是 P(t) = (1−t)²A + 2t(1−t)B + t²C —— 三个权重是 0.25 / 0.50 / 0.25(t = 0.5 时),和恰好是 1。
最该记住的一句:t = 0 与 1 代进去,只有 A 与 C 的权重不为零(各为 1),所以曲线经过首末点; 而 B 的权重在 t = 0.5 处最大也只有 0.5,**永远到不了 1** —— 所以曲线不经过 B。 本文档里这叫"逼近样条":控制点是拉力,不是必须落在曲线上的点。
第二条性质也在这里第一次出现:三个权重都非负、加起来是 1, 于是曲线上的点永远是控制点的凸组合 ⇒ 整条曲线待在控制点围成的凸包里(判据②量的就是这一条)。

20.3 段 · 一般的 n:一个三角形的账

本段只增加一样东西:控制点数任意(3 到 7;其余控件一致)。 保真度:示意 · 等价重现。上一段只解决了"多一个点"的做法,可真实造型要用几十个点。 同一件事往上长很直接:n+1 个控制点就把规则用 n 次,画出来是一个三角形阵(金字塔), 行数就是次数。而"用了 n 次"这件事可以一次算完 —— 把系数摊开就是 Bernstein 形式。 两条路的数必须逐位相同,这构成一条真正的交叉验证。

输入 控制点数 n+1 3 – 7 可切 t 仍是同一个 两条路线 ① 金字塔 ② Bernstein 读数 层数 = 次数 权重和 = 1 两条路线 最大差 3e−13 曲线永远在 控制点的凸包里 ③ 两条路线:重复施加 vs 一次算完(都真跑) function binom(n, i) { // 帕斯卡三角的递推 let r = 1; for (let k = 0; k < i; k++) r = r * (n - k) / (k + 1); return r; } function bernstein(P, t) { // 一次算完 const n = P.length - 1; let x = 0, y = 0; for (let i = 0; i <= n; i++) { const w = binom(n, i) * Math.pow(1 - t, n - i) * Math.pow(t, i); x += w * P[i].x; y += w * P[i].y; } return { x: x, y: y }; } // 两条路线**不共用任何中间量**:一条在几何上 // 一层层取点,一条把系数一次乘完 ⇒ 才是交叉验证 层数 = 次数 3 点 → 3 行 4 点 → 4 行 6 点 → 6 行 权重和 ≡ 1 最大 4.4e−16 n=3、t=0.5 .125/.375 .375/.125 见判据③⑤ 五档见表③
定点观察:把「控制点数」从 3 一路点到 7 → 看画面里金字塔从 3 行长到 7 行、以及右列"按权重叠出来的点"怎么越来越靠近中间那些控制点 → 行数永远比控制点数少 1(行数 = 次数);而不管几个点,所有点都是控制点的加权平均 (权重非负、和恒为 1)⇒ 曲线永远待在凸包里,且两条路线算出来的点逐位相同。
表③ 一般的 n:重复施加(金字塔)与一次算完(Bernstein)必须逐位相同。n 取 3(4 个控制点)与 5(6 个控制点)各三档
控制点数t金字塔(重复施加)Bernstein(一次算完)差 (px)

算式:B(t) = Σᵢ C(n,i)·(1−t)^(n−i)·t^i·Pᵢ,其中 C(n,i) = n!/(i!(n−i)!)。 把 t + (1−t) = 1 代进二项式定理就得到 Σᵢ C(n,i)(1−t)^(n−i)t^i = 1 —— 这就是权重和恒为 1(判据⑤ 实测 4.4e−16),也是凸包性的根。
两条路线必须不共用中间量:一条在几何上一层层取点,一条把系数一次乘完(二项式系数由帕斯卡递推算), 所以它们给出的"最大差 3.46e−13"才叫证据 —— 若两边照抄同一个表达式,差恒为 0,等于没验。
★ 层数这件事值得单独记一句:n+1 个控制点 ⇒ n 层 ⇒ 次数 n。 所以这一段的标题里那个"三角形的账"就是指金字塔 —— 它的两腰(每层的首与尾)在下一段会派上大用场。

20.4 段 · 两条接起来:接缝处要什么条件

本段只增加一样东西:第二条曲线的控制点(画面上是 8 个点;其余控件一致)。 保真度:示意 · 等价重现。上一段把"一条曲线"讲完了,可一条曲线只有 n+1 个控制点, 形状自由度有限(而且改一个点整条都会动)。真实造型是把很多段接起来 —— 于是问题变成:接的地方要满足什么,看起来才不像断了一截? 答案就藏在上一段已经算出来的东西里:端点切线。

输入 两段三次曲线 8 个控制点 共享接缝点 Q₀ 两档 C¹ 共线等距 C⁰ 只共享端点 读数 接缝夹角 挪一点的位移 条件 P₂ → Q₀ → Q₁ 共线且等距 两端切线 都是那条弦的倍数 ④ 端点切线:接缝处光不光滑只看这两条(都真跑) function tangent(P, atEnd) { // B'(0) = n(P₁−P₀) const n = P.length - 1; const a = atEnd ? P[n - 1] : P[0]; const b = atEnd ? P[n] : P[1]; return { x: n * (b.x - a.x), y: n * (b.y - a.y) }; } // 接缝处的夹角 = 两条端点切线的夹角 function seamDeg(P, Q) { const t1 = tangent(P, true), t2 = tangent(Q, false); const d = t1.x * t2.x + t1.y * t2.y; const L = Math.hypot(t1.x, t1.y) * Math.hypot(t2.x, t2.y); return Math.acos(d / L) * 180 / Math.PI; // 0 ⇒ C¹ } // C¹ 的条件:P₂ → Q₀ → Q₁ 走同一条直线、且比 1:1 // 只挪 Q₁ 一个点 ⇒ 第二段曲线位移有上界 4/9·|δ| 接缝夹角 C¹ = 0.000000° C⁰ = 22.6199° 接缝点位置 两档差 0 只挪 Q₁ 位移 84.85 px 最大 37.71 px 闭式 4/9·|δ| 两者逐位相同 见判据⑨
定点观察:在「C¹(共线等距)」与「C⁰(只共享端点)」两档之间来回切 → 看接缝处那两条虚线切线是否共线,以及表④ 的夹角读数 → 夹角从 22.6199° 掉到 0.000000°:两段能接得光滑,条件只是"P₂ → Q₀ → Q₁ 三点共线且等距" —— 而这个条件的来处,正是上一段那条 B'(0) = n(P₁−P₀)。
表④ 两条三次曲线接起来。第一列是独立判据(用叉积算共线度,不走 acos),第二列由本页的 seamDeg 给出;最后一列是"只挪 Q₁ 一个控制点"对第二段曲线的影响,闭式是三次曲线里 P₁ 的权重 3t(1−t)² 的极大值 4/9
档共线度 |t₁×t₂| / (|t₁||t₂|)接缝夹角 (°)接缝点位置差 (px)只挪 Q₁(84.85 px)后第二段最大位移 (px)

算式:三次曲线在 t = 0 处的切向量是 B'(0) = 3(P₁ − P₀)。 把它用在"两段接起来"上:第一段的尾切线是 3(P₃ − P₂),第二段的头切线是 3(Q₁ − Q₀); 而两段共享 P₃ = Q₀。于是两条切线共线的条件就是 P₂、Q₀、Q₁ 三点共线 —— 再要求等距(比 1:1),两条切向量就完全相等,接缝处连"速度"都接上了,这就是 C¹。
顺带的定量结论:只挪 Q₁ 一个点、位移 84.85 px 时, 第二段曲线最多移动 4/9 × 84.85 = 37.71 px(实测 37.71,逐位相同)。 这个 4/9 的来处很好记:三次曲线里 P₁ 的权重是 3t(1−t)²,它在 t = 1/3 取极大 4/9 —— 所以控制点的影响是有上界的,这是"控制点不是曲线上的点"最直白的一个推论。

课末:把"夹"拆成四件事

这一课只做了一件事:把"在两点之间取一点"这条规则用起来。四段各加一个变量,加完全都清楚了:

下一课(课题十 · 第 2 课):细分 —— 一张粗网格怎么自己长细。 这一课把规则用在一排点上;下一课把同一条规则装到一个环上(面点 / 边点 / 顶点点各取一次加权平均), 于是它就能处理任意拓扑的网格 —— 而"任意拓扑"这四个字也正是第 4 课那本账(半边结构)的来处。

自检面板

等待脚本…