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

细分:一张粗网格怎么自己长细

全课题骨架:几何不是"画"出来的,是控制点 + 一条可以反复施加的规则算出来的 —— 而那条规则只做一件事:在两点之间取一点。
第 1 课把这条规则用在一排控制点上(曲线)。可真实模型的网格不是一排点 —— 它是每个点周围围着一圈面。于是"在两点之间取一点"要升级成三条同时作用的取平均规则。
四段路:先把每条边切一半(21.1)→ 新点按规则缩进去(21.2)→ 老顶点也要动(21.3)→ 一直做下去(21.4)。
★ 这一课最值钱的一件事是量出来才发现的:三条规则只做两条,曲面比不改还糟 —— 角上的最大二面角从 90° 涨到 116.18°;三条齐了才单调下降(90° → 7.06°)。 保真度:示意 · 等价重现 —— 三条规则与计数都是纯算术 (页面现场跑的十条与离线定稿 q202.mjs 逐位相同),画面是本页自己的二维软件光栅 (正交投影 + 平面着色 + 线框),不是 GPU 或 DCC 的曲面渲染。 ★ 本页只做闭合四边形网格的内部规则:不含边界规则(开放网格的边点 / 顶点另有取法), 也不含半锐折痕(semi-sharp creases);本页不声称极限曲面是球 —— 一手源原话是 "although it looks like the bi-cubic interpolation approaches a sphere, an actual sphere is quadric"。

分段:
迭代 k: k = 1 ← 读者全程只动这一个旋钮
显示: |拖画面可以转(正交投影)

(脚本启动中…)

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

本帧的读数

(脚本启动中…)

21.1 段 · 先把每条边切成两半

本段只增加一样东西:一次切分(规则档 = 只切分;读者只动「迭代 k」)。 保真度:示意 · 等价重现。网格太粗就得先变密。最省事的做法是"每条边取中点、每个面取中心",再连起来。 注意这一档只动新点:老顶点一个都不挪 —— 于是它其实只是把同一个多面体切成更多小块。

输入 立方体网格 V=8 E=12 F=6 半边 1,闭合 切分一次 边 → 中点 面 → 面心 读数 26 / 48 / 24 最大内缩 0 形状 一点没变 二面角恒 90° 见判据③⑨ ① 邻域账本 + 只切分(本页真跑的几行) function topology(mesh) { // 边 → 两个面 mesh.faces.forEach((f, fi) => { for (let k = 0; k < f.length; k++) { const a = f[k], b = f[(k + 1) % f.length]; edges.get(key).f.push(fi); // 一条边两个面 } }); function ruleSplit(P1, P2, F1, F2) {// ① 只切分 return mul(add(P1, P2), 0.5); // 边中点 } const VP = mesh.verts.map((P) => P.slice()); // 这一档老顶点一个都不挪:形状当然不变 // 新面:每个 k 边形 → k 个四边形 faces.push([offV + a, eIdx.get(key), offF + fi, eIdx.get(keyPrev)]); k = 0 → 1 8 / 12 / 6 26 / 48 / 24 χ = V−E+F 2(不变) 最大内缩 0.000000 最大二面角 90.000000° k = 4 时 1538 点 还是 90°
定点观察:把「迭代 k」从 0 一路点到 4 → 看画面里的形状有没有变、以及表① 的"最大内缩"与"最大二面角"两列 → 顶点从 8 涨到 1538,形状一点没动:切分只是"变密",它与"变光滑"是两件事 —— 这正是下一段的缺口。
表① 只切分档:切多少次,形状都不变。最大内缩 = 顶点到原立方体表面的最大距离(半边 1);最大二面角 = 相邻两面法线的最大夹角
kVEFχ = V−E+F最大内缩最大二面角 (°)

算式:一次切分给每个面加一个面心、每条边加一个中点,再把它们连起来 —— 四边形网格下 F' = 4F、E' = 2E + 4F、V' = V + E + F。 从立方体(8/12/6)出发:F₁ = 24、E₁ = 2·12 + 4·6 = 48、V₁ = 8 + 12 + 6 = 26。
为什么形状一点没变:这一档算出来的新点全落在原来的边与面里(中点、面心),老顶点一个不挪, 所以每个顶点到原立方体表面的距离恰好是 0。切分是纯拓扑操作 —— 它让网格更密,但一点都不更光滑。要让它"缩进去",得给新点一条加权规则,那是 21.2。

21.2 段 · 新点不落在老位置上

本段只增加一样东西:面点与边点的加权规则(规则档 = 加面点与边点;读者仍只动「迭代 k」)。 保真度:示意 · 等价重现。上一段的失点是"新点也落在老面上",所以形状一点没变。 现在把边点换成四点平均:(P₁ + P₂ + F₁ + F₂)/4 —— 它把两个相邻面点也算进来, 于是新点会离开原来的表面。

输入 闭合四边形网格 每个点度 n 每条边两个面 两条规则 面点 = 面内平均 边点 = 四点平均 读数 面心 (0, 0, 1) 边点 (¾, ¾, 0) 最大内缩 0.25 = 1/4 见判据④⑤⑧ ② 面点与边点(本页真跑的两行) const FP = mesh.faces.map((f) => avg(f.map((i) => mesh.verts[i]))); // 面点 function ruleEdge(P1, P2, F1, F2) { // ② 面边 return mul(add(add(P1, P2), add(F1, F2)), 0.25); } // 规矩与上一段逐字相同:(A+F+M+E)/4 // A、F 是这条边的两个相邻面点;M、E 是两端点 // 两个手算靶子(半边 1 的立方体): // 面点 = 面心 = (0, 0, 1) // 棱 2–6 的边点 = (¾, ¾, 0) // 独立于规则的一条账(判据⑤): // Σ_f n_f·F_f = Σ_v deg(v)·v // 平移 (3,−2,5) 后两边都 = (72, −48, 120) 面点 (0, 0, 1) 棱 2–6 边点 (¾, ¾, 0) 最大内缩 0.250000 棱角 k=1 仍 90° k=4 变 116.18° ★ 只做两条 反而更尖 见判据⑨
定点观察:切到「加面点与边点」、把 k 拉到 1,再一路拉到 4 → 看八个角是不是更扎眼了、以及读数里的"棱角"那一项 → k = 1 时棱角还是 90°,可 k = 4 时涨到 116.18°:只做这两条规则,曲面比不改还糟 —— 因为新点缩进去了、老角却还钉在原地。这是下一段的缺口。
表② 加面点与边点这一档的三个手算靶子,外加一条独立于规则的账。手算那一列是拿笔能算出来的(半边 1 的立方体)
量手算(拿笔)实测差

算式:面点 F_f = (1/k)·Σ_{v∈f} v(k 是该面的顶点数);边点 E_e = (P₁ + P₂ + F₁ + F₂)/4。 拿半边 1 的立方体算两个靶子:
① 面 [4,5,6,7] 的四个点是 (−1,−1,1)(1,−1,1)(1,1,1)(−1,1,1), 平均恰好是 (0, 0, 1) —— 面心。
② 棱 2–6(从 (1,1,−1) 到 (1,1,1))被 x=1 与 y=1 两个面共享, 这两个面的面点是 (1,0,0) 与 (0,1,0),于是 E = ((1,1,−1)+(1,1,1)+(1,0,0)+(0,1,0))/4 = (¾, ¾, 0)。 它的最大坐标是 0.75,所以离原立方体表面缩进了 1/4(这正是判据⑧ 中间那一档)。
那条独立的账:Σ_f n_f·F_f(每个面点乘以该面的顶点数再求和) 恒等于 Σ_v deg(v)·v(每个顶点乘以它的度再求和)—— 因为左边不过是"每个面先把面内的点加起来、再乘回顶点数",交换求和次序就变成"每个顶点被它所属的每个面各算一次"。 它不依赖任何一条规则,所以是**真验证**:立方体每个顶点度 3, 平移 (3, −2, 5) 后两边都应该是 3 × 8 × (3, −2, 5) = (72, −48, 120)。

21.3 段 · 老顶点也要动

本段只增加一样东西:顶点点规则(规则档 = 完整 CC;读者仍只动「迭代 k」)。 保真度:示意 · 等价重现。上一段的漏点很具体:新点缩进去了,老顶点却还钉在原地, 于是角上被拉得更尖。补上第三条规则就齐了:老顶点按 (F + 2R + (n−3)P)/n 走,其中 F 是相邻面点的平均、R 是相邻边中点的平均、 n 是这个顶点的度。

输入 老顶点 P 度 n = 相邻边数 = 相邻面数 两个平均 F = 相邻面点平均 R = 相邻边中点平均 规则 (F+2R+(n−3)P)/n 权重 (n−3):2:1 读数 角点 → 5/9 权重和 = 1 见判据⑥⑦ ③ 顶点点规则(本页真跑的那几行) function vertexRule(mesh, i, topo) { const P = mesh.verts[i]; const fs = [...topo.vf.get(i)], es = [...topo.ve.get(i)]; const n = es.length; // 度 = 相邻边数 let F = [0, 0, 0], R = [0, 0, 0]; // 两个平均 for (const fi of fs) F = add(F, facePoint(mesh, fi)); for (const key of es) R = add(R, edgeMid(mesh, topo, key)); F = mul(F, 1 / fs.length); R = mul(R, 1 / es.length); return mul(add(add(F, mul(R, 2)), mul(P, n - 3)), 1 / n); } // 三个权重 (n−3)/n : 2/n : 1/n 之和恒为 1 // ⇒ 新点仍是凸组合 ⇒ 仍在原点的凸包里 // 角点 (1,1,1)、n = 3:F=(⅓,⅓,⅓)、R=(⅔,⅔,⅔) // (F + 2R)/3 = (5/9, 5/9, 5/9) // ★ n = 3 时自己那一项权重恰好是 0 // 平面网格上(F = R = P)规则把点留在原地 角点 (1, 1, 1) → (.555556)³ = 5/9 n = 3 的权重 0 : ⅔ : ⅓ 和 = 1(浮点 ε) 平面扇形 位移 ≤ 5.2e−17 棱角 90 → 43.34 → 7.06°
定点观察:切到「完整 CC」、k = 1,再一路拉到 4 → 看八个角是不是被"抹"掉了、以及读数里的"棱角"与表③ 的权重那一列 → 角点从 (1,1,1) 走到 (5/9, 5/9, 5/9),而三个权重 0 : ⅔ : ⅓ 之和恰好是 1: 新点一定还在原点的凸包里;而度 3 的顶点连"自己"那一项都不含 —— 它纯粹是邻居的平均。
表③ 顶点点规则的三个权重:(n−3)/n : 2/n : 1/n。最后一列是"平面正则扇形"(一个点周围围着一圈正多边形、且全在同一平面上)时规则给出的位移
度 n(n−3)/n2/n1/n权重和平面正则扇形位移

算式:P' = (F + 2R + (n−3)P)/n,等价地写成三个权重 ((n−3)/n)·P + (2/n)·R + (1/n)·F —— 三者之和数学上恒等于 1。 ★ 但浮点上它不是逐位的 1:2/3 + 1/3 在双精度下是 0.9999999999999999, 所以判据写的是"|和 − 1| ≤ 1e−15",表③ 那一列显示的是打印出来的 1.000000 —— "显示 1"与"逐位是 1"是两件事,这与第 1 课那条"差 0 与差 1e−13 都对"是同一类话。 权重非负、和为 1,所以新位置是 P、R、F 的凸组合:它一定还在原点的邻域里。
★ 度 3 的顶点最特别:(n−3)/n = 0,自己那一项整个消失, 新位置就是 (F + 2R)/3 —— 拿立方体的角点算一遍: F = ((1,0,0)+(0,1,0)+(0,0,1))/3 = (⅓,⅓,⅓)、 R = ((1,1,0)+(1,0,1)+(0,1,1))/3 = (⅔,⅔,⅔), 于是 P' = (F + 2R)/3 = (5/9, 5/9, 5/9) ≈ (0.5556, 0.5556, 0.5556)。
平面网格上它不动:平面上一个正则邻域里 F = R = P,代进去得到 nP/n = P —— 所以细分不会把一块平的地方弄皱(判据⑥ 对 n = 3…8 逐档实测,位移只有 1e−17 量级,那是浮点零)。

21.4 段 · 一直做下去

本段只增加一样东西:迭代次数 k(规则档 = 完整 CC;读者动的还是那个 k)。 保真度:示意 · 等价重现。上一段只做了一刀,形状"更像样"了,可真正要的是一直做下去。 这一段要收的两笔账都能量:计数(顶点 / 边 / 面怎么涨、欧拉示性数变不变)与棱角 (相邻面的最大二面角随 k 怎么走)—— 后者正好把三条规则"缺一不可"变成三条可分开的曲线。

输入 完整 CC 规则 三条都齐 迭代 k = 0…4 两笔账 计数 V/E/F 棱角(最大二面角) 读数 k=4:1538 点 χ = 2 不变 棱角三条线 切分 90° 水平 面边升到 116° CC 降到 7.06° ④ 迭代与计数(本页真跑的几行) function iterate(tier, k) { let m = cube(); for (let i = 0; i < k; i++) m = subdivide(m, tier); return m; } // 计数递推(四边形网格;本页自己推的) // F' = 4F 每个面 → 4 个面 // E' = 2E + 4F 老边各裂成 2,面内再添 4 // V' = V + E + F 老点 + 边点 + 面点 // 棱角 = 相邻两面法线的最大夹角(度) // 只切分:一直是 90°(还是那个立方体) // 面边:90 → 101.82 → 111.16 → 116.18 ★ 更尖 // CC: 90 → 43.34 → 26.46 → 13.93 → 7.06 // ⇒ 三条规则缺一不可:少一条,曲线就往上翘 k = 4 V 1538 E 3072 F 1536 棱角 切分 90.00° 面边 116.18° CC 7.06° 细 4 次 点数是原来的 192 倍 见判据①②⑨
定点观察:在「完整 CC」上把 k 从 0 拉到 4,再切到另外两档各拉一遍 → 看表④ 里三档二面角那三条曲线(切分是水平线、面边往上翘、CC 往下走) → 只有三条规则齐了,曲线才往下走:这就是"缺一不可"的可量形式 —— 而 21.2 那一档(面边)从 90° 涨到 116.18°,比什么都不做还尖。
表④ 迭代的账。前三列是三档规则各自的"棱角"(最大二面角,度);后两列是完整 CC 档的规模与最大内缩。k = 0 那一行就是立方体本身
k切分 (°)面边 (°)完整 CC (°)CC 的 V / E / FCC 最大内缩

算式:每一刀都与 k 无关 —— 规则不知道自己在第几轮,所以"迭代"就是第 1 课那条 "把同一条规则重复施加"的原样照搬。计数由此变成干净的递推: F' = 4F、E' = 2E + 4F、V' = V + E + F —— 从 8/12/6 出发得到 26/48/24 → 98/192/96 → 386/768/384 → 1538/3072/1536, 而 χ = V − E + F 恒等于 2(判据①② 逐档实测)。 每切一刀面数正好乘 4,所以 k 刀之后面数是原来的 4^k 倍(k = 4 ⇒ 1536/6 = 256 = 4⁴)。
★ 棱角那三列才是这一段的正题: 切分档永远是 90°(它画的还是那个立方体); 面边档反而往上翘(90° → 116.18°)—— 新点缩进去了,可八个老角还钉在原地,于是角上更扎眼; 只有完整 CC 单调下降(90° → 7.06°)。三条规则缺一不可,这句话到这里才第一次有了方向。
顺带一条如实说明:本页不声称极限曲面是球 —— 最大二面角趋于 0 说的是"越来越光滑", 不是"越来越像球"(一手源原话:它看起来像双三次插值趋近球,但真正的球是二次曲面)。

课末:把"长细"拆成四件事

这一课只做了一件事:把第 1 课那条规则换到一个环上重复。四段各加一个变量:

下一课(课题十 · 第 3 课):参数曲面 —— 把一条曲线"拉"成一整张面。 这一课让网格自己变密;下一课换一条路:在两个方向各用一次第 1 课的规则(张量积), 于是曲面成了"两条曲线之间的插值",而 NURBS 的有理权重能让它画出真正的圆。

自检面板

等待脚本…