生成网格中的破洞从何而来
作者:Oleg Sidorkin,Cinevva CTO 兼联合创始人

我用 TRELLIS.2 生成了一只鸟,给它绑定骨骼,而动画一开始播放,两侧翅膀下方就出现了破洞。透过那些亮色细缝,可以直接看到身体另一侧。最先背锅的是绑定器,它确实也有自己的问题,但在任何骨骼接触网格之前,这些洞就已经存在了。本文记录了随后进行的调查:这些洞究竟是什么、九种无效的修复方案及其失败原因,以及最终真正奏效的唯一方案。我有意保留了所有弯路。其中大部分都是人们最先会尝试的显而易见的方法,而每一次失败,都让我们学到了最终修复所依赖的东西。
第一步:先统一什么叫“洞”
我们的网格检查器报告边界边数量为零,射线奇偶测试也表明表面是封闭的。所以我告诉团队,网格没有问题。事实并非如此,而那个把细缝标红的人坚持反驳是对的。网格可以是封闭的,却仍然存在隧道。唯一能检测出它们的指标是欧拉特征数,也就是 V 减 E 加 F,它能给出每个壳体的柄数。这只鸟有 69 个。导出结果中的每面墙都是双层的,因此,如果外壁上的裂缝与其后方的空腔相连,它就是一个柄,而不是开口;任何只查找开放边的方法都永远看不到它。
第二个教训更令人尴尬:我花了一段时间探测暗色像素来找洞,因为洞通常是暗的,直到有人让我先看看图片。那些洞是亮的。那是远端墙壁上的绿色纹理透过灰色褶皱形成的细缝。如果没有让算法对准正确的目标,它给出的结果毫无意义,而最便宜的瞄准方法就是亲眼看一看。
第二步:找出产生破洞的阶段
TRELLIS.2 分三步构建网格。解码器首先以 1024 立方分辨率输出原始表面。然后由窄带双重轮廓算法对其进行重网格化。最后,减面器把面数降到十万,并烘焙纹理。我们在每一步之后都导出了几何体,并每次都让同一束射线穿过翅根。
问题从原始表面就开始了。它是一堆开放面片:有 266,242 个边界环,其中大多数只是缺少一个四边形,还有 65,371 条非流形边。在翅根位置,三分之一的测试射线直接穿了过去。下一步运行的孔洞填充几乎什么也没封上——五百万个面里只填了 144 个。重网格化器基于无符号距离场工作,因为它只能如此。它没有内部或外部的概念可用,因此会在每个面片周围包裹一层很薄的偏移壳。这就是为什么每面墙都是双层的,两层之间大约相隔四个体素;也正因如此,每条多孔条带都会变成一道带裂缝的褶皱。之后的减面会让情况恶化,而且是随机恶化:同一个原始网格减面两次,分别产生了 69 条和 107 条隧道。

这张图给出了第一条真正的线索。多孔区域并不是随机分布的。它们出现在每个部件连接处,而其他地方完全没有。
那些没有奏效的方法
我们大致按照它们看起来有多显而易见来依次尝试。
| 尝试 | 结果 |
|---|---|
| 将分辨率从 1024 提高到 1536 | 五个测试模型中有三个变得更糟。更高的分辨率会解析出更多细薄接触面,而每一个都会形成新的连接处 |
| 内置清理流程 | 非流形边不减反增 |
| 使用 MeshLib 填充每个小洞 | 填了 26.6 万个环,翅根却毫无变化。那里从未生成表皮,因此根本没有环可填 |
| 根据绕数进行有符号重网格化 | 网格碎裂成数千个壳体。原始输出没有可供判定符号的内部 |
| 将轮廓带拓宽到 2 和 3 个体素 | 隧道总数减少了,但裂缝变宽而不是变窄。小隧道合并成了大隧道 |
| 对体素结构进行形态学闭运算 | 壳体被实心化,随后解码器在每个内部体素中生成表面,面数变为 4 倍,并产生 969 条隧道 |
| 对最终网格进行全局体素重网格化 | 亏格变为 0,裂缝也封住了。但每根羽毛的边缘都被磨钝。看一眼就被否决了,而且否决得完全正确 |
| 偏移解码器的交叉阈值 | 开放边从 6.5 万降到 5.8 万。只是噪声 |
| 补全缺失的相邻体素 | 整只鸟只有 560 个案例。只是噪声 |

全局闭运算值得多说几句,因为这是建模师最先会想到的方法:以略大于缝隙的尺寸进行一次体素重网格化。从拓扑上看,它确实有效。但它也歪曲了模型本身。生成器原本生成了清晰锐利的羽毛边缘,而我们却要拿这些边缘去换一个封闭的翅根。九种尝试中有两种确实降低了隧道数量,但只要有人不看数字、转而看一眼鸟的实际效果,它们就立刻被否决了。
阅读代码
到了这一步,唯一诚实的做法就是停止把解码器当作黑盒,直接查看它的输出是如何变成网格的。TRELLIS.2 使用一种名为 O-Voxel 的表示方式,其提取代码非常短。每个活跃体素都包含一个对偶顶点和三个 logit,分别对应从该体素角点出发的三条网格边,每个 logit 都表示“表面是否穿过这条边”。当 logit 大于零时,就会生成一个连接该边周围四个体素的四边形。如果四个体素中有任何一个缺失,这个四边形就会被静默丢弃。全部逻辑就是这样。模型中不存在符号场、占据场,也完全没有内部或外部的概念。
我们修改了解码器,让它在阈值处理前导出这些 logit,然后在同一只鸟上进行了统计。
缺失的相邻体素:270 万个体素中有 560 个。接近零的 logit:800 万个中有 8,600 个。两者都不是问题。真正的问题只有在查看每个网格面的四条边时才会显现。封闭表面与一个面的边界相交时,交点数应当是偶数。但有 126,542 个面——占总数的 2.3%——其四个标志彼此矛盾,而根据定义,每个奇数交点面都会形成开放边或非流形边。而且这种矛盾非常“自信”。在只有一个交叉标志的面上,那个孤立标志的 logit 是 +10,而相邻标志则是 -4。翻转一个标志以恢复奇偶一致性所需的成本中位数是 2.3 个 logit。这不是阈值稍有偏差的问题。模型非常确信这里存在一个根本无法闭合的表面。

接下来,我们测量了条带本身,位置就是固定相机整整一周都在盯着的那个精确表面点。在其周围十二个体素的范围内,活跃体素数量是单个平面所需数量的 2.1 倍。相比之下,胸部只有 1.2 倍。翅膀下表面和身体是两层皮肤,却在同一个体素层内延伸。这种表示方式每个体素只能容纳一个顶点,因此无法同时表示两层表面。训练过程中,这类体素的真值是两层表面的最小二乘平均,并配合“任意交叉”标志;这种真值本身就是不一致的,所以模型忠实地学会了在那里生成一把梳子。重网格化器把这把梳子包裹成厚片,而厚片中就形成了隧道。
条带的宽度可以由几何关系推导出来:大约等于一个体素除以接触角的正切值。两个表面以九十度相交,只会形成一个体素宽的折痕,不会出问题。但翅膀平贴身体、羽毛彼此贴合、脚趾接触地面时,都会产生十到十五个体素宽的条带。这就解释了那张多孔区域分布图。
我确实尝试过更符合原理的修复方法:推断一个最能解释这些标志的逐角点内外符号,再据此重新推导标志。但这行不通。符号场只存在于解码器生成的单体素壳层上,任何向壳层外的扩展都会失控。迭代十二轮后,体素数量翻了一倍,新增的却全是垃圾。
建模师会怎么做
建模师绝不会让两层表皮只隔着一根头发丝的距离。他们会把翅膀与身体做布尔并集,只留下一条折痕。全局闭运算会对所有地方都这么做,而问题恰恰在此。因此,只在两层表皮并存的位置处理即可。
检测器的思路很简单。统计每个体素周围四个体素范围内的活跃邻居数量。在单层表面上,这个数字处处相同,也就是中位数。两层表面共享同一体素层时,它大约会翻倍。标记任何超过中位数 1.5 倍的位置,将标记区域向外扩张一个体素,再在每个位置放置一个单位立方体。两层表皮之间的空间会变成实心,轮廓提取过程只会看到一面墙,而模型的其他任何部分都不会受到影响。

我们先在局部裁剪区域上测试了它,用 MeshLib 暂时代替 GPU 轮廓提取。53 个壳体和 19 个柄变成了 2 个壳体和 5 个柄。然后,我们把它加入真正的流水线,放在解码器与导出之间,并让同一只鸟通过真正的重网格化器和真正的纹理烘焙流程。

| 隧道 | 非流形边 | 翅根翼片 | 背部破洞 | |
|---|---|---|---|---|
| 原始状态 | 79.5 | 318 | 存在 | 存在 |
| 填充,半径 3 | 84.5 | 97 | 消失 | 消失 |
| 填充,半径 4 | 72 | 38 | 消失 | 消失 |
翼片消失了。鸟背上的另一个洞也消失了——那是从背后看到的同一个连接处,我之前甚至没有刻意处理它。羽毛仍然彼此分离,脚趾也保持分离;纹理能够正常烘焙,因为这些立方体位于壳体内部,而属性体积在那里本来就有数据。对于一个耗时四分钟的任务,它只增加了六秒。现在它已默认启用,半径为四。

这个方案无法修复什么,以及它说明了什么
隧道数量几乎没有变化,我想坦诚解释原因。剩余的隧道位于羽毛尖端和脚趾尖端,也就是细薄部件的末端,双层壁会在那里收拢闭合。它们很小,不可见,而且由另一种机制导致。该填充方案针对的是部件之间的接触区域,而可见破洞正是出现在这些地方。 真正的修复必须从上游着手。如果解码器预测的是每个角点的符号,而不是每条边的标记,那么它在构造上就能保证奇偶一致性。上一代模型正是采用这种方式,因此生成的网格是水密的。这需要改变训练方式,无法在推理阶段通过补丁解决。在此之前,我们可以从条纹公式中得出两条实用结论。如果你要生成角色,让四肢与身体分开的姿势会比四肢紧贴身体的姿势生成更干净的网格。如果你要在这类生成器的基础上进行开发,请使用欧拉特征数检测网格,并在相信数值之前亲眼查看结果。数值显示网格没有问题,但它两次都错了。
经过插桩的解码器、各阶段的数据转储、奇偶性分析和孔洞填补功能如今都已纳入我们的流水线,因此下一个模型再生成带孔洞的网格时,我们只需几分钟就能完成同样的处理,而不再需要一周。