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

参数曲面:把一条曲线拉成一整张面

全课题骨架:几何不是"画"出来的,是控制点 + 一条可以反复施加的规则算出来的 —— 而那条规则只做一件事:在两点之间取一点。
第 1 课把这条规则用在一排控制点上(曲线),第 2 课把它装到一个环上(细分)。 这一课问的是第三个问题:怎么从"一条线"直接得到"一整张面"? 答案只有一行 —— 两个方向各做一次第 1 课的事:先按列取点,得到一排新点;再对这一排取点。
四段路:把两个方向乘起来(22.1)→ 看清"面只在边界上听话"(22.2)→ 给控制点加一个权重、把抛物线变成圆(22.3)→ 两个方向都加权重,得到一个精确的环面(22.4)。
★ 量出来才敢写的一件事:曲面上的点只受"整片"控制网影响,可四条边界曲线只受边界那一行/列影响 —— 扰一个内部点,四条边界的位移恰好是 0(实测 2.22e−16,浮点意义下就是没动)。 以及:画一个精确的 90° 圆弧,中间那个权重不是拍出来的,是 w = sin(α/2) —— 实测 0.707106781。 保真度:示意 · 等价重现 —— 张量积 / 有理求值都是纯算术 (页面现场跑的十条与离线定稿 q203.mjs 同口径),画面是本页自己的二维软件光栅 (正交投影 + 平面着色 + 线框),不是 GPU 或 DCC 的曲面渲染。 ★ 本页只做矩形控制网上的张量积与有理二次 / 双二次:不含 B 样条与节点向量(那是"非均匀"那一半), 也不含裁剪与求交;22.3 的圆弧是同一页自己算的,不调用任何几何库。

分段:
探针 u: v: (u, v) = (0.50, 0.50)
权重 w: w = 1.000000
显示: |拖画面可以转(正交投影)
扰哪个点(22.2): 角点 P00

(脚本启动中…)

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

本帧的读数

(脚本启动中…)

22.1 段 · 两条曲线乘起来,就是一张面

本段只增加一样东西:第二个参数(控制网从"一排"变成"一片";读者只动「探针 (u, v)」)。 保真度:示意 · 等价重现。一条曲线是一排控制点;一张面就一片控制点,排成若干行若干列。 求值只有一行念法:先按列取点(对每一行做一次第 1 课的"在两点之间取一点",得到那一行在 u 处的点), 于是一排新点;再对这一排取点(对 v 再做一次)。本页把行/列的对应钉死一次: 行 i 由 v 混合、列 j 由 u 混合 —— 固定 v 得到的曲线叫等参曲线。 ★ 这一段的账是"两条独立路线":可分式 de Casteljau(逐层取点)与直接张量积(Bernstein 双和) 不共用任何中间量,625 档扫下来最大差 5.44e−16。

输入 控制网 3×3 9 个控制点 3 行 × 3 列 约定 行 i 由 v 混合 列 j 由 u 混合 做法 先按列取点 再对这一排取 读数 两条路线 5.44e−16 见判据① ① 张量积:两个方向各做一次第 1 课的事(本页真跑的几行) function dc1(pts, t) { // 第 1 课那一行 const q = pts.map((p) => [p[0], p[1], p[2]]); for (let r = q.length - 1; r > 0; r--) for (let i = 0; i < r; i++) q[i] = lerp3(q[i], q[i + 1], t); return q[0]; } function surfEval(net, u, v) { // 本课新增的一行 return dc1(net.map((row) => dc1(row, u)), v); } // 先按列→再按行 第二条路线(逐字对上,不共用中间量): function surfBern(net, u, v) { // Bernstein 双和 let s = [0, 0, 0]; for (let i = 0; i <= m; i++) for (let j = 0; j <= n; j++) s = V.add(s, V.mul(net[i][j], bern(m, i, v) * bern(n, j, u))); return s; } 右列读数 两条路线 625 档 最大差 5.44e−16 四个角点 差 0 / 1.2e−16 / 2.2e−16 / 0 权重和 Σ B·B − 1 ≤ 4.44e−16 非负 ⇒ 面在凸包内 见判据①②③
件一:22.1 的张量积。左列是输入与约定,中间是本页真跑的源码逐字, 右列是两条路线的实测差。中间框里 surfEval 只有一行 —— 它把第 1 课那行规则用了两次。
件三 · 表① 张量积的两条路线(可分式逐层取点 | Bernstein 双和 | 差); 末行是 2×2 双线性的手算靶子:S(0.5,0.5) 必须恰好等于四角平均。
uv可分式 S(u,v)直接张量积差

定点观察:① 拖「探针 u / v」两个滑块;② 看画面上从两条等参曲线上各走出来一个点、 汇到那个黄点的地方;③ 结论:面上任何一点都由"两次取点"得到 —— 先在一排点上取出中间点, 再在这些中间点之间取出最终点;把行当成列(i/j 与 u/v 对调)会得到另一张面,而不是同一张。

22.2 段 · 面只在边界上听话

本段只增加一样东西:把控制点分成"角 / 边 / 内部"三类(读者只动「探针」与三类控制点的注视点; w 仍为 1)。保真度:示意 · 等价重现。上一段会算面上的点了,可"这个面到底受哪些控制点管"还不清楚。 一张 3×3 的控制网里,9 个点并不是平等的:四个角点落在面的四个角上; 四条边上的中间点只落在某一条边界曲线的内部;正中间那个点一个边界都碰不到。 ★ 一句能拿笔验的话:四条边界曲线只由边界那一行 / 那一列控制点决定 —— 扰正中间的点(位移 0.692820),四条边界的位移恰好是 0(实测 2.22e−16)。

输入 3×3 控制网 角 4 | 边 4 内部 1 问 谁管哪条边界 做法 扰一个点 量四条边界 读数 角 2 条 边 1 条 内部 0 条 见判据④⑤⑥ ② 等参曲线的控制点 + 边界只认边界(本页真跑的几行) function isoCtrl(net, v) { // 固定 v 的等参曲线 const out = []; // 它的控制点不是某一行 for (let j = 0; j <= n; j++) { let s = [0, 0, 0]; for (let i = 0; i <= m; i++) s = V.add(s, V.mul(net[i][j], bern(m, i, v))); out.push(s); } // R_j(v) = Σ_i B_i(v)·P_ij return out; } // v = 0 时 R_j(0) = P_0j ⇒ 那条边界就是第 0 行 // v = 1 时 R_j(1) = P_mj ⇒ 那条边界就是第 m 行 扰一个点,量四条边界(每条 21 档,取最大位移): function dist4(net, nat) { // 同上四字逐字 return [V.dist(net, nat, t, 0), // v = 0 那条 V.dist(net, nat, t, 1), // v = 1 那条 V.dist(net, nat, 0, t), // u = 0 那条 V.dist(net, nat, 1, t)]; } // u = 1 那条 内部点扰动时这四行全为 0 ⇒ 边界没听见 右列读数 角点 P00 动 2 条 6.93e−1 边点 P01 动 1 条 3.46e−1 内部 P11 动 0 条 2.22e−16 扰动模 0.692820 见判据④⑤⑥
件一:22.2 的等参与边界。左列是三类控制点,中间那三行就是"边界只认边界"的来处, 右列是三类点各自动了几条边界(扰动模 0.692820)。中间的下半段是本页真跑的 dist4 四行 —— 它就是"量四条边界"这件事本身。
件三 · 表② 扰一个控制点、四条边界各动多少(扰动位移 = (0.4, 0.4, 0.4),模 0.692820; 每条边界取 21 档最大位移)。末列是"动了几条边界"。
扰动哪个点v = 0v = 1u = 0u = 1动几条

定点观察:① 在画面里看三个高亮位置(角点 / 边点 / 内部点,由段按钮下的读数指示); ② 看四条边界曲线里哪几条跟着变色;③ 结论:曲面上"受不受影响"不是靠距离近不近, 而是靠"在不在那一行那一列上" —— 正中间那个点离四条边界都不远,可它一条都管不到。

22.3 段 · 一个权重,把抛物线变成圆

本段只增加一样东西:每个控制点配一个权重 w(读者只动「权重 w」)。 保真度:示意 · 等价重现。前两段的面全是多项式的 —— 而多项式画不出圆: 这条有一手源里逐字的写法("A nondegenerate circular arc is not a polynomial parametric curve"), 本页也把它量了出来(把权重全取 1,同一个 90° 弧的最大半径偏离就有 6.07e−2)。 差在哪儿?给每个控制点配一个权重:分子是 Σ w·B·P,分母是 Σ w·B,最后除一下。 权重不是"比例",它是一个拉力:中间那个点的权重越大,曲线越被拽向它。 ★ 中间那个权重不是拍出来的:一手源给的闭式是 w = sin(α/2)(α 是控制多边形在中间点处的顶角)—— 本例 α = 90°,所以 w = sin45° = 0.707106781,代进去半径偏离 3.33e−16。 ★ 权重还决定这条曲线的类型:w < 1 椭圆、w = 1 抛物线、w > 1 双曲线(一手源原话), 本页用隐式判别式 B²−4AC 逐档量了出来。

输入 三个控制点 (1,0) (1,1) (0,1) 顶角 α 控制多边形在 中间点的夹角 权重 w = sin(α/2) = 0.707106781 为什么能画圆 抬成 (w·P, w) 再投影回 w = 1 见判据⑦⑧⑨ ③ 有理曲线:分子 / 分母(本页真跑的几行) function ratCurve(pts, ws, t) { // 权重乘在每一项上 const n = pts.length - 1; let num = [0, 0, 0], den = 0; for (let i = 0; i <= n; i++) { const b = bern(n, i, t) * ws[i]; num = V.add(num, V.mul(pts[i], b)); den += b; } return V.mul(num, 1 / den); } // 再除以权重和 第二条路线(逐字对上,不共用中间量): function ratCurveHomog(pts, ws, t) { // 抬成 (w·P, w) let q = pts.map((p, i) => [ws[i]*p[0], ws[i]*p[1], ws[i]*p[2], ws[i]]); // …四维逐层取点(与第 1 课同一件事)… return [q[0][0]/q[0][3], q[0][1]/q[0][3], q[0][2]/q[0][3]]; } // 再投影 两条路线都在本页跑:差 ≤ 3.14e−16 权重全取 1 ⇒ 退回多项式,画不出圆 右列读数 圆档 w = √2/2 |P| − 1 ≤ 3.3e−16 多项式档 w = 1 最大 6.07e−2 类型(B²−4AC) 0.5 椭圆 1.0 抛物线 1.5 双曲线 见判据⑦⑧⑨
件一:22.3 的有理曲线。左列是输入与闭式权重,中间是本页真跑的两条路线 (分子/分母 | 齐次抬高再投影),右列是实测。中间第 6 行那个 1 / den 就是"有理"两个字的全部内容 —— 分母一出现,曲线就不再是多项式,于是它能弯成圆。
件三 · 表③ 上半:90° 圆弧上的点与半径偏离(圆档 w = √2/2 | 多项式档 w = 1)。 下半:权重 w 决定圆锥曲线类型(隐式判别式 B²−4AC;括号里是隐式残差,越大越说明"它真的在那条圆锥曲线上")。
档点 或 判别式|P| − 1 或 残差是哪条曲线

定点观察:① 拖「权重 w」,先停在 1(多项式),再拖到 √2/2; ② 看画面里那条三维曲线(齐次空间里的抛物线)与它投到 w = 1 平面上的影子; ③ 结论:抬高那一维之后曲线永远是"多项式"的(所以是抛物线),是"除回去"这一步把它变成了圆 —— 权重不改变分子上的形状,它改变的是"从哪儿看"。

22.4 段 · 两个方向都带权重,得到一个精确的环面

本段只增加一样东西:第二个方向也带上权重(读者只动「权重 w」;w 现在作用在扫掠方向)。 保真度:示意 · 等价重现。上一段只把一个方向变成了有理。要让整张面都精确,两个方向都得有理: 控制点仍是 3×3,权重取两套权重的外积(剖面那一套 × 扫掠那一套)。 于是整张面就是"一个精确圆弧 + 一个精确剖面"扫出来的东西 —— 一个精确的环面。 ★ 判据不靠眼睛:环面有隐式方程 (|S|² + R² − r²)² = 4R²(x² + y²), 把面上 625 个点代进去,最大残差 3.55e−15(相对 4.87e−16,就是浮点零); 把扫掠方向的权重全改成 1(退回多项式),同一组点上的残差立刻涨到 4.52e−2 —— 那就不是环面了。 四条边界也都是精确圆弧(半径误差 ≤ 3.89e−16)。

输入 3×3 控制网 剖面圆 R=1 r=.35 扫掠圆 半径 1 权重 两套权重的外积 (1, .7071, 1)² 问 它是不是环面 判据 隐式方程残差 相对 4.87e−16 见判据⑨⑩ ④ 有理曲面:第二个方向也带权重(本页真跑的几行) function ratSurfSep(net, ws, u, v) { // 先按 u 再按 v const qs = [], oms = []; for (let i = 0; i <= m; i++) { let num = [0, 0, 0], den = 0; for (let j = 0; j <= n; j++) { const b = ws[i][j] * bern(n, j, u); num = V.add(num, V.mul(net[i][j], b)); den += b; } qs.push(V.mul(num, 1 / den)); // 这一行在 u 处的点 oms.push(den); } // 连同它自己的权重 let num = [0, 0, 0], den = 0; // 再按 v 有理求值 for (let i = 0; i <= m; i++) { const b = oms[i] * bern(m, i, v); num = V.add(num, V.mul(qs[i], b)); den += b; } return V.mul(num, 1 / den); } ★ 那个 oms[i] 是关键:上一层的分母要   跟着点一起传下去,否则第二层权重错 两条路线(直接双和 vs 这一支)差 ≤ 7.45e−16 右列读数 环面残差 相对 4.87e−16 对照:扫掠 权重全 1 4.52e−2 四条边界 半径误差 ≤ 3.89e−16 全取 1 时 = 多项式面 差 5.00e−16 见判据⑨⑩
件一:22.4 的有理曲面。左列是输入(3×3 网 + 两套权重的外积),中间是本页真跑的 两层有理求值,右列是隐式方程残差与四条边界的半径误差。中间那两个 1 / den —— 一个在每一行里,一个在最终一遍里;少了 oms[i] 那一传,第二层的权重就错了。
件三 · 表④ 扫掠方向的中间权重 w 取四档,看 u = v = 0.5 处那个点还满不满足环面的隐式方程 (R = 1、r = 0.35)。只有 w = √2/2 那一档残差落到浮点零。
w(扫掠中间)S(0.5, 0.5)隐式残差是不是环面

定点观察:① 拖「权重 w」,从 1 拖到 √2/2,再拖到 0.6; ② 看画面里那四条高亮的边界圆弧与曲面整体的形状;③ 结论:只有 w = sin(α/2) 那一档, 整张面上的每一个点才真的落在环面上 —— 差一点点,曲面看着"差不多是个环",可它已经不是了 (残差从 1e−16 跳到 1e−1,差 15 个数量级,眼睛完全分不出来)。

本页用到的做法,出处在哪

保真度与不做的部分:本页的控制网、有理求值、隐式方程残差全部由本页自己的代码算出 (页面现场与离线定稿 q203.mjs 同口径),画面是本页的二维软件光栅(正交投影 + 平面着色), 不声称与任何 DCC / GPU 的曲面渲染逐位相同。本页不含 B 样条与节点向量、不含裁剪与求交、 不含三个方向以上的推广。

自检面板

等待脚本…