如果你做过体素数据转网格,一定被两个名字反复折腾过:Marching Cubes和Dual Contouring。MC这边稳如老狗,随便给个体素场就能出mesh;DC那边虽然流程绕一点,但遇到需要保留棱边和角点的模型时,效果是真的顶。这篇文章不是复读教科书,而是把两套算法各自在做什么、为什么一个输出圆滚滚的形状、另一个能保留尖角,以及真实项目里怎么选型,一次理清楚。
在看代码和论文之前,我得先给个结论:MC和DC并不是“谁替代谁”的关系。它们面对的数据都是同一个东西——三维标量场,但思路从中间就分岔了。MC是在体素边上插值出表面点,DC是在体素内部求解出一个最优顶点。就这么一个差异,直接决定了两者的网格形态、锐利程度、顶点数量和应用场景。下面我按自己实际踩坑的顺序来拆。
1. 先聊清楚这两种算法到底在解决什么问题
1.1 三维重建的“标量场”到底长什么样
正经做三维重建,不管是医学CT、工业零件扫描、NeRF隐式场,还是SDF距离场,最后走到网格化这一步,你手里的数据基本都能抽象成同一个东西:一个定义在规则网格上的标量场。
这个网格里的每个节点,存着一个数。这个数在CT里是组织密度,在SDF里是到最近表面的带符号距离,在NeRF里是密度或者occupancy。我们要做的,就是把“这些数相同”的曲面从体素格子里“捞”出来,捞出来的东西才是可以拿去渲染、仿真、3D打印的三角网格。
这个“捞”的过程,学术上叫等值面提取(isosurface extraction)。等值面本身是一个连续的隐式曲面,Marching Cubes和Dual Contouring都是把这个隐式曲面离散成三角网格的经典方案。它们做的事情本质上一样:给定一个阈值,找到场里所有等于这个阈值的位置,连成一张面。但实现路径完全不同,结果也完全不同。
1.2 为什么不能直接用点云重建替代
这个问题我经常被问。既然已经有CT扫描得到的点云或者NeRF输出的密度场,为什么不能像激光雷达点云那样,直接跑一遍Delaunay三角化或者Poisson重建?非要用MC/DC这种基于体素的算法?
原因在于:很多三维数据并不是“点”的形式输出的。CT断层扫描天然就是规则网格上的密度值;SDF的每一项也对应空间中的一个体素;NeRF输出更是得靠采样查询才能得到点。而且体素本身带拓扑,MC/DC能在相邻体素之间建立确定的邻接关系,生成的网格一定是流形、无自交的。点云重建算法处理这种稠密规则数据反而慢,还要额外处理法线估计、空洞修补。体素类算法是“顺着数据本身的组织结构”来工作,无论从效率还是稳定性上都更贴合这类场景。
1.3 一个关键前提:你得先有“连续的场”
聊算法之前先泼一盆冷水。MC/DC这类算法对输入有一个隐性要求:标量场最好是连续变化的,至少在跨等值面的方向上有平滑过渡。
如果你手里的数据是二值的,比如体素块只有0和1,没有中间值,那直接用MC会得到方块感极强的网格,DC也会因为法线估计不稳定而输出一堆碎面。实际操作中,我会先把二值体素数据做一次距离变换,让每个体素存“到最近占用点的距离”,然后取阈值0,再用MC/DC提取。这一步看着多花时间,实际效果天差地别。
所以做任何体素重建之前,先确认你的场是不是连续场。不是,就先做预处理。这是所有后续对比的基础。
2. Marching Cubes:一张表打天下,但打不了锐利的仗
2.1 8个顶点、256种组合与15种典型状态
Marching Cubes是我入行最早接触的算法,1987年提出的老古董,但现在依然是医学影像重建的事实标准。它的核心思想非常暴力也很直接:把每条边“切开”。
假设你有一个体素立方体,它有8个顶点。每个顶点都已知标量值,并且和阈值比较后只会得到两个结果:在表面内部,或者外部。8个顶点每个有两种状态,所以一共有2的8次方,也就是256种组合。
论文作者发现,这256种情况通过旋转、镜像、顶点状态取反等对称操作,最终可以归约成15种不同的拓扑状态。这15种状态就是一张查找表。运行时,对每个体素cube,拿到它8个顶点的内外状态,去表里一查就知道:这个cube和等值面相交了哪几条边,需要生成几个三角形,三角形顶点怎么连。
这就是Marching Cubes名字的由来:一个格子一个格子地“行军”,每个格子走一遍同样的流程。查找表的好处是快到极致,关键就是这张表的正确性。
2.2 边上插值求交点,法线靠梯度估算
但表里只告诉我们拓扑连接方式,真正的顶点位置还得算。这里用的是线性插值。
比如cube的一条边,两个端点的标量值一个大于阈值、一个小于阈值,那等值面肯定穿过这条边。交点位置就按两端点的值做线性插值确定。这一步对MC来说极其重要,因为MC产出的网格顶点永远落在体素边上,而不是体素内部。
法线怎么算?等值面的法线在数学上就是标量场的梯度方向。一般做法是用中心差分先算出8个顶点的梯度,然后沿着刚才插值得到的交点在边上再做一次线性插值,得到该交点处的法线方向。这个法线在后面做光照、做纹理映射的时候都会用到。
2.3 二义性问题:一张表不是万能的
教科书不太爱讲,但实际做MC最容易翻车的点,是二义性。
15种基础配置里,有一种常见情况:cube的四个顶点状态呈对角分布——比如左上和右下是内部,右上和左下是外部。这时候等值面穿过的路径有两种连法,而且两种连法在几何上都说得通。问题在于,相邻的两个cube如果选了不同的连法,它们共享边界上的三角化就会不一致,结果就是网格上出现裂缝或者孔洞。
这个问题在1990年代被反复研究。解决办法有很多,最简单的方案是改用Marching Tetrahedra(MT),把每个cube劈成5个或6个四面体,再在每个四面体上做同样的插值提取,从根上消除二义性,代价是每个cube生成的三角形数量变多。更好的做法是在查表时加入“渐近决策”(Asymptotic Decider),通过比较等值面在体素面上的渐近交点来选正确的连接方式。我用到的pymcubes和skimage里的Lewiner实现,都已经把二义性处理好了,不建议自己从零写表,除非你想掉一层皮。
2.4 MC真正的软肋是特征钝化
二义性还只是正确性问题。MC真正的软肋,是它对锐利特征完全无能为力。
因为MC的顶点永远只可能落在体素的12条边上,它永远无法表达“体素内部有一个角点”这种情况。如果原始模型有一条棱边穿过体素内部,MC只能把这条棱边表示成台阶状的一串顶点。分辨率不够时,锐利边缘就变成了圆角。你拿MC重建一个正方体,理论上得到的表面在棱角处一定是圆形过渡的,这叫特征钝化。
很多做CAD重建的同事对MC嗤之以鼻就是因为这个。但必须承认,对于CT这类噪声不小的数据,MC这种“圆滑”反而是优点,因为它相当于内置了一点平滑。这也是为什么医学影像重建至今还是MC的地盘。
3. Dual Contouring:不插值沿边,从内部“长”出一个顶点
3.1 Hermite数据:不只要交点,还要法线
Dual Contouring是2002年由Ju等人在SIGGRAPH上提出的。它的英文名里这个“Dual”很关键:MC是在体素边上找交点,DC则是在每个体素内部放一个顶点,然后把这些顶点连起来形成网格——网格的顶点和体素的关系,刚好是“对偶”的。
DC的输入不再只是标量场,它需要每个边上都带法线信息,这种数据叫Hermite数据。具体说,当一条体素边被等值面穿过时,我们不仅记录交点位置p,还记录等值面在该点的法线n。法线的来源可以是SDF的解析梯度,也可以是对标量场做中心差分后的归一化结果。
就是多了一个法线,让DC拥有了远远多于MC的几何信息。法线本质上告诉算法:等值面在这个交点附近是往哪个方向切的。几个不同方向的法线一交汇,锐利特征的位置就暴露了。
3.2 QEF求解:用切平面找交点
有了Hermite数据后,DC在每个体素里要解决一个数学问题:找到一个点v,让v到所有相交边的切平面的距离平方和最小。这个距离度量就是二次误差函数(Quadratic Error Function),简称QEF。
每个相交边上的交点p和法线n,定义了一个切平面:n·(x - p) = 0。我们希望找到最优的x,使得所有切平面的误差总和最小。把方程写成矩阵形式:
A x = b
其中A的每一行是法线n,b的每一项是n·p。由于方程数通常多于未知数,这是一个最小二乘问题。解出来x就是体素内部最接近真实角点的位置。
如果体素内部刚好有一个锐角,几个不同方向的切平面会交汇于一点,QEF的最小值会落在一个很接近真实角点的位置。这就是DC能保留棱边的数学原因。
但这里有个工程细节:当体素内部的法线方向几乎一致时,QEF对应的最小二乘矩阵是奇异的,解会不稳定,得到的顶点可能漂移得很夸张。所以实现里通常都会给对角线加一个很小的正则项,比如λI(λ取1e-6到1e-3),保证解不飞到天上去。后面章节我会细说这个坑。
3.3 网格连接方式完全不同
MC的三角化是体素内部的连接:一个cube直接输出若干个三角形。DC则不同,它每个cube只生成一个顶点,这些顶点之间怎么连成面?
逻辑是把相邻体素的顶点相连:考虑每一个体素边,它被4个cube共享,这4个cube各自的内部顶点可以连成一个四边形。每个体素边对应一个四边形面,把所有四边形合在一起,再沿着对角线劈成三角形,就得到了三角网格。
这个流程和MC查表输出的三角网格形态有明显差异。DC输出的网格更加紧凑,顶点数量少一个数量级都不奇怪,而且面片的拓扑和体素边是对应的,非常适合做后续的网格简化。代价是:每个四边形不一定是平面四边形,劈成三角形时有多种劈法,虽然一般不影响网格质量,但某些极端情况下会造成面片扭曲。
3.4 为什么DC对噪声敏感
DC看着比MC高级,但用起来翻车概率也高。最大的原因在于它对法线太敏感了。
如果输入的标量场带噪声,中心差分求出来的法线方向会乱抖,QEF的求解就会被这些噪声法线带偏。每个体素内部的顶点位置都会受影响,最终输出的网格表面会出现大量凹凸不平的“麻点”。MC的插值顶点则被限制在边上,噪声的影响范围有限,生成的面反而更稳。
所以DC适合用在两种地方:一是SDF这类本身法线可以解析求出来的数据;二是数据噪声已经处理干净的场合。如果你手里是一堆乱七八糟的体素,直接上DC大概率会得到一个让强迫症崩溃的网格。
4. 同场景实战对比:拿同一份数据跑MC和DC
4.1 评测数据与准备
为了说清楚差异,我用两组数据跑了一次对比测试。
第一组是一个512x512x512的CT体素数据,来自真实的膝关节扫描。这类数据天生有噪声,密度值跨度和CT值单位相关,阈值选择我用了Otsu自动阈值确定骨骼区域。
第二组是一个512³的SDF体素场,用一个带倒角、孔洞的机械零件模型生成。对SDF我可以精确求梯度作为法线,数据干净得堪比教科书。
运行环境:一台普通工作站,i7-12700,32GB内存。MC用pymcubes,DC用我自己实现的一个版本,两者都开多线程。每个cube的计算是完全独立的,很适合并行,我按tile分块跑了8路并行。
4.2 对比指标
除了直观的效果之外,我记录了四类核心指标:
- 顶点数和三角形数:直接影响渲染开销和文件大小。
- 锐利特征保留情况:在机械零件上重点看倒角边缘是否清晰。
- 生成耗时的差异:同一个体素场,两边各跑一遍。
- 网格质量:尤其是检查是否有孔洞、非流形边、碎片面片。
4.3 结果一览
| 指标 | MC(CT数据) | DC(CT数据) | MC(SDF零件) | DC(SDF零件) |
|---|---|---|---|---|
| 顶点数 | 约1300万 | 约370万 | 约420万 | 约95万 |
| 三角形数 | 约2600万 | 约740万 | 约840万 | 约190万 |
| 锐边保留 | 差,倒角变圆 | 中等,受噪声影响 | 差,台阶感明显 | 好,棱线清晰 |
| 孔洞情况 | 无(库已处理二义性) | 少量碎面 | 无 | 无 |
| 耗时 | 约1.9秒 | 约3.4秒 | 约1.5秒 | 约2.6秒 |
| 网格可编辑性 | 差,面太多 | 一般 | 差 | 好,几乎可直接用于简化 |
这个表格里的数据是基于我反复跑出来的一个典型结果。具体数值会随体素大小和复杂度变化,但规律是稳定的。最直观的感受是:MC网格的顶点数通常是DC的三到五倍,而DC的耗时通常比MC多50%到100%。省下来的顶点数在后续网格简化、物理仿真里是非常划算的买卖。
4.4 为什么差异会这么大
先看顶点数。MC每个cube和等值面相交的每条边都要生成一个顶点,排列密集,顶点天然就多。DC一个cube只生成一个顶点,哪怕这个cube内部有复杂的特征,也尽可能用一个顶点去表达,输出自然紧凑。
再看法线对结果的放大作用。在CT数据上,DC其实没有得到理想的结果。因为CT的密度场经过中心差分求法线时引入了不少噪声,QEF解出来的顶点位置时常偏离真实表面。MC由于只做线性插值,反而没那么容易受噪声影响。这一点也提醒了我:DC虽然上限高,但对输入数据质量的下限要求也高。
锐边保留的差异最明显。在SDF零件数据上,MC生成的倒角边缘始终有一层圆滑过渡,看起来像被倒过圆角;DC则把原本的方棱方角基本还原了出来。对于CAD模型这类本身就棱角分明的数据,DC的效果是压倒性的。
5. 工程落地选型:手里的数据形态决定一切
5.1 医学影像和扫描设备优先考虑MC
如果你接的是CT、MRI或者工业CT这类扫描数据,我的建议简单粗暴:直接用MC,别折腾DC。
理由有两个。第一,扫描数据的标量场里本来就有噪声,法线信息是估算出来的,DC引入的不稳定因素会抵消它理论上保留特征的优点。第二,医学重建最看重的是拓扑正确性和平滑度,根本不在乎那点锐利特征,MC生成的网格加上适当的拉普拉斯平滑,效果已经很专业。
工程上我推荐直接用VTK的FlyingEdges3D,它是MC的高性能并行实现,速度比普通MarchingCubes快很多。Python环境里也可以用skimage.measure.marching_cubes(Lewiner实现),它处理二义性比老版本好,输出直接是顶点、面和法线,拿来就能用。
5.2 隐式神经场重建按输出特性分情况
NeRF、NeuS这类隐式神经场重建,输出的是体素密度场或SDF场,选择逻辑比较复杂,得看你采出的场是什么类型。
如果输出是密度场(比如传统NeRF的density),我不建议直接上DC。因为密度场的梯度方向和真正的表面法线经常不一致,DC会被误导。这种情况用MC更稳妥,配合适当的densisty阈值调整,能出不错的mesh。
如果输出的是SDF场(比如NeuS、VolSDF),那DC是更好的选择。SDF自带精确梯度,法线可以直接解析求出来,DC完全是为了这种数据设计的。很多神经重建算法的官方管线后面接的都是DC的变体,保留表面细节的同时还能控制三角形数量。
5.3 网格后处理:DC生成的东西更适合直接剪
做三维重建的人都知道,MC输出的网格面数大,往往要跑一遍简化(decimation)才能用。但简单化本质上是砍掉不重要的细节,它不像DC那样一开始就只保留最关键的几何信息。
实际项目里我自己的管线长这样:如果需要快速出预览,MC直接出结果,然后跑一边Quadric Error Metrics简化到目标面数。如果需要最终资产质量,优先考虑DC,输出后只做很小的拉普拉斯平滑和网格修复,省掉大量简化步骤。
另外值得一提的还有OpenVDB,它内部用的表面提取算法是VDB格式下的经典方案,思想源头与DC一脉相承。处理超大稀疏体素场时,OpenVDB的稀疏编码能省下海量内存,这是MC那种规则遍历方案做不到的。如果你的数据是百万级别以上的体素,强烈建议调研一下这套工具链。
6. 踩过的坑和绕行方案
6.1 MC输出网格有裂缝?多半是查表二义性
我第一次自己写MC时,输出的网格在没人的角落裂了一堆洞。查了半天发现是对角点状态相反的二义性cube,查找表直接给的拓扑是错的。后来我换成了更完善的查找表,并且在cube的每个面上做了一次二义性判断,问题才消失。
给后来人的建议:能用成熟库就别手写。PyMCubes里的Lewiner实现、VTK的FlyingEdges3D,这些都是别人踩平了无数坑之后的产品。真要想练手写一遍,记得必须处理二义性,不要迷信原始论文的15种配置表。
6.2 DC的QEF解漂移:用正则化压住
DC最大的坑在QEF求解不稳定。当体素内相交边数量少、法线方向几乎一致时,最小二乘矩阵接近奇异,解出的顶点可能直接飞出体素范围,网格上会出现尖刺。
我的处理办法是给QEF矩阵对角线加一个小正则项,比如λ取1e-6到1e-3,具体值看场的大小。还有一种更稳的做法:对QEF矩阵做特征值分解,把特别小的特征值方向上的自由度钳制住,只允许在法线变化剧烈的方向自由移动。这样既保留锐利特征,又不会让顶点漂移。
6.3 法线方向不一致会让DC彻底崩溃
这个坑冷门但致命。Hermite数据里的法线必须满足一个约定:始终指向等值面的同一侧,要么都朝外,要么都朝内。如果计算法线时没有统一方向,有的指向外侧、有的指向内侧,QEF的切平面方程会自相矛盾,解出来的顶点位置会整体偏到一边。
检查方法很简单:随机抽几个体素边上的交点,把法线和梯度方向点乘一下,符号应该一致。我自己曾在一个SDF重建项目里吃过这个亏,排查了两天最后发现是某段代码把法线反了。
6.4 体素分辨率对结果的影响比算法本身还大
调了那么多参数之后我发现,分辨率才是最大的变量。同样的零件,256³体素下MC和DC差距不大,到了1024³时DC保留锐边的优势才会完全显现。分辨率不足时,MC和DC都会漏掉细小特征,区别只是漏得圆润还是漏得尖锐。
所以工程上做体素化之前要先问自己:目标特征多大?需要的表面法线精度够不够?如果体素分辨率达不到特征尺寸的1/3,换再好的提取算法都白搭。
一点个人体会
这套东西玩久了,我现在的习惯是接到任务先问一句:数据是连续场还是稀疏点?法线能不能拿?噪声大不大?这三个问题问完,选MC还是选DC基本不需要犹豫。MC是那种“什么时候都能用”的老实人,DC是“条件对了就超神”的高手。没有谁完全取代谁,只有数据适不适合。
如果你想亲手验证这两套算法的差异,我的建议是从同一个SDF场开始,用MC先出一版,再用DC出一版,放在同一场景里旋转对比。看到同一份数据变成两种完全不同的网格时,你对这两个算法的理解会比读十篇论文都扎实。