相信有不少朋友在看三维动画时会发出一个问题,类似这样的流体模拟的特效是如何做到的?笔者小时候也非常好奇,尤其是那种水珠融合在一起变成水面的过程,在学习设计专业的时候,常常想做出那种具有流动感的设计作品,却常常不知从何下手。最近笔者借着疫情时间,好好探究了一下实现融合效果的几何算法:Marching Cubes和Metaballs,并且研究了一下两个算法的GPU实现。
算法简介
Marching Cubes算法是计算机图形中的常用算法,由Lorensen和Cline于1987年的SIGGRAPH提出。它的作用是从三维标量场中提取等值面网格。该算法具有非常广泛的应用,尤其是在医学领域中的CT扫描和MRI扫描,根据数据图像来生成三维模型,同时还常应用于CG行业,将Marching Cubes中的提取面算法部分用于Metaballs来用于三维建模、流体模拟等视觉特效。
除此之外,Marching Cubes(从三维体素中提取三角形网格)还有它的二维版本,称之为Marching Squares(从二面矩形中提取闭合曲线),以及另一个三维常用算法Marching Tetrahedra(从三维体素中提取四面体网格)。
算法应用
首先说说两个算法的应用,Marching Cubes和MetaBalls在游戏、动画、医疗、设计等诸多行业中都有重要的应用。例如在游戏和动画领域,常用于交互式造型、地形生成、流体模拟等,在医疗领域中常用于根据医疗扫描设备获取的图像来进行曲面重建,在设计中常用于参数化设计。
视频链接:https://www.youtube.com/channel/UCmtyQOKKmrMVaKuRXz02jbQ
知乎上有人使用MarchingCubes算法实现了《魔兽争霸3》的地图编辑器:
https://zhuanlan.zhihu.com/p/78875252
设计师Jesse Jackson将Marching Cubes的基本情形做成了标准单元体,通过组合来作为一种数字建造的新方法。
算法原理
Marching Squares原理
由于Marching Cubes是个非常有名的算法,并且网络上参考资料比较丰富,因此本节的算法介绍,笔者根据参考资料来简单叙述。
为了方便理解三维的Marching Cubes中提取面的部分,我们不妨先从二维的Marching Squares算法中提取二维封闭曲线的原理开始讲起,下面是算法的步骤分解:
-
假设我们有一个平面图形(长得好像一根鸡腿)
-
接着,我们在平面内绘制格网,如图10所示
- 然后我们要找出格网上有哪些格点是在我们的
鸡腿内部平面图形内部,所有位于内部的格点标记成红色,外部的格点标记为蓝色,得到图11.
-
同时呢,我们也能找出格网中哪些边与我们的平面图形边界相交(其实就是那些两端点异色的边),找出这些边并标记它们的中点为紫色。
-
最后,只需要将紫色点连接起来,就可以得到重构后的平面图形。
二维的算法就是如此简单,当然,我们的格网划分地越细,重构的平面图形就越接近于我们的原始图形,当然这也意味着要求更大的计算量。付出更多的计算时间。
注意:上述示例其实是Marching Squares的一种改写。因为原算法是根据二维标量场来进行绘制的,也就是说,最初是没有用户输入的二维平面图形的,输入的应该是一个函数,算法通过这个函数来计算出每个格点上的值,根据值来确定哪些格点在最后提取的曲线内部或外部。这部分在下文仍会再提到
Marching Cubes原理
同理,我们也可以在三维空间中执行上述二维的步骤,只不过实现的过程稍微复杂一些,主要的区别如下:
- 二维的格网在三维中变成了三维的体素(立方体)
- 二维中连接两点的线变成了三角面片
- 闭合的二维图形变成了三维的网格模型
那么具体如何从三维体素中提取等值面呢?
输入:场函数,iso值,体素的数量(X,Y,Z三个方向的数量)
输出:三维网格模型
1. 根据输入的体素数量构建体素格网。
2. 根据场函数计算每个顶点的值
我们前面提到,Marching Cubes是从三维标量场函数中提取等值面的,因此我们要做的事其实就是对每个顶点计算它们各自的值,这个值由指定的场函数决定,但是因为场函数多种多样,我们这里先抛开场函数不提,只关心从体素中提取面的过程,所以这里假定我们计算好了8个点上各自的值,记为。
3. 标记每个顶点位于等值面的状态
在上一步中,我们计算出了每个点的值,可以想象出,所有的点上的值构成了一个区间,我们可以在这个区间中定义一个值,也就是iso值。iso值就是我们所说的等值面的值,所谓提取等值面就是寻找体素格网中那些根据场函数计算出的值等于iso值的点所构成的曲面。这张曲面把体素中所有的格点分成了两部分,我们可以规定,格点上的值小于iso值的格点在曲面的内部,格点上的值大于iso值的格点在曲面的外部。也就是说,这个值的作用是帮助我们判定哪些点在等值面内,哪些点在等值面外。关于标记每个体素中顶点的状态(记为cubeindex),我们可以使用一个8位的二进制数来记录,位于等值面内部的顶点标记为1,位于等值面外部的顶点标记为0,我们将这样的二进制数记录于每个体素中。
int cubeindex = 0;
if (voxel.v[0] < isolevel) cubeindex |= 1;
if (voxel.v[1] < isolevel) cubeindex |= 2;
if (voxel.v[2] < isolevel) cubeindex |= 4;
if (voxel.v[3] < isolevel) cubeindex |= 8;
if (voxel.v[4] < isolevel) cubeindex |= 16;
if (voxel.v[5] < isolevel) cubeindex |= 32;
if (voxel.v[6] < isolevel) cubeindex |= 64;
if (voxel.v[7] < isolevel) cubeindex |= 128;
4. 根据顶点状态找出活跃边
Marching Cubes算法提供了一个边表(Edge Table),可以根据每个体素的二进制数来判断出等值面所经过该体素的哪些边,这些经过等值面的边称为活跃边。边表如下
int edgeTable[256]={
0x0 , 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c,
0x80c, 0x905, 0xa0f, 0xb06, 0xc0a, 0xd03, 0xe09, 0xf00,
0x190, 0x99 , 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c,
0x99c, 0x895, 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90,
0x230, 0x339, 0x33 , 0x13a, 0x636, 0x73f, 0x435, 0x53c,
0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30,
0x3a0, 0x2a9, 0x1a3, 0xaa , 0x7a6, 0x6af, 0x5a5, 0x4ac,
0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0,
0x460, 0x569, 0x663, 0x76a, 0x66 , 0x16f, 0x265, 0x36c,
0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963, 0xa69, 0xb60,
0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0xff , 0x3f5, 0x2fc,
0xdfc, 0xcf5, 0xfff, 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0,
0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x55 , 0x15c,
0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950,
0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6, 0x2cf, 0x1c5, 0xcc ,
0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0,
0x8c0, 0x9c9, 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc,
0xcc , 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9, 0x7c0,
0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c,
0x15c, 0x55 , 0x35f, 0x256, 0x55a, 0x453, 0x759, 0x650,
0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc,
0x2fc, 0x3f5, 0xff , 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0,
0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f, 0xd65, 0xc6c,
0x36c, 0x265, 0x16f, 0x66 , 0x76a, 0x663, 0x569, 0x460,
0xca0, 0xda9, 0xea3, 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac,
0x4ac, 0x5a5, 0x6af, 0x7a6, 0xaa , 0x1a3, 0x2a9, 0x3a0,
0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x83f, 0xb35, 0xa3c,
0x53c, 0x435, 0x73f, 0x636, 0x13a, 0x33 , 0x339, 0x230,
0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c,
0x69c, 0x795, 0x49f, 0x596, 0x29a, 0x393, 0x99 , 0x190,
0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905, 0x80c,
0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x0 };
5. 线性插值求出活跃边与等值面的交点
找到了一个体素中的活跃边后,我们可以通过线性插值来求解出这些边与等值面的交点,找到等值面真正经过的顶点。线性插值公式为:
其中和为体素中的边上的两个顶点,和分别为两个顶点的值,为用户输入的iso值,顶点为等值面与边的交点。
举个例子,如图所示,对于每一个体素,都有8个顶点(Vertices)12条边(Edges),我们分别用字母和来标记。
假如只有3号顶点位于等值面内,那么我们可以计算出该体素的二进制数为,对应边表查得的结果为edgeTable[8] = 1000 0000 1100,也就对应着边2、3和11经过了等值面。接着我们可以计算插值点,找到活跃边与等值面的交点。
5. 根据每个活跃体素的顶点的状态查找三角面表,生成网格模型
类似于图17的方式,我们可以对每个体素执行这样的操作。但是这里有人可能会想到,图17只展示了一个点位于等值面内的情况,如果一个体素中同时有2个顶点或者更多顶点位于等值面内该如何处理呢?
首先我们可以计算出,体素有8个顶点,所以会有种可能,下图是Marching Cubes算法的15种基本情形,其他241种情形可以通过这15种基本情形的旋转、映射等方式实现。
因此我们可以通过查找这个三角面表来找到每个体素中的三角面片。最开始Lorensen和Cline提出了一个三角面片查找表,他们将一个体素内所有可能的情况全部一一列举了出来,但是由于最初的三角面片查找表存在不完整、内插歧义等问题,经过数次修改,现在我们在使用的是Geoffrey Heller提出的三角面片查找表。
最后对每一个体素执行查找操作,提取出三角面片最后生成网格模型。至此我们就完成了三维的Marching Cubes算法。
三维算法的执行过程
三维算法的执行过程用动图表达:
算法效率问题
了解了Marching Cubes算法,不难想到,如果要得到高精度的圆滑曲面,我们需要计算非常大量的体素格网,然而算法执行中要对每一个体素进行扫描:首先要判断该体素是否为活跃体素(等值面经过的体素),然后才能开始计算来生成网格模型。实际上在大量的体素中,经常绝大部分体素都是空体素(等值面不经过的体素),这些空体素的判断严重影响着算法的执行效率,因此我们必须采取一些措施来减少算法的复杂度。
关于等值面提取加速的算法有很多,常见的方法有:
- 采用带有最大最小值的八叉树和KD-Tree
- 按需分叉策略
- GPU加速:Exclusive Scan和Histogram Pyramids[1]
GPU加速的Marching Cubes
笔者主要使用了GPU加速的方法来解决算法的低效问题,使用C++及CUDA完成Marching Cubes的加速计算部分。实现过程中参考了CUDA官方的示例代码[7],本人的源代码已开源至github:https://github.com/AlbertLiDesign/ALG_MarchingCubes_GPU
算法步骤如下:
输入:场函数,iso值,体素的数量(X,Y,Z三个方向的数量)
输出:三维网格模型
- 分类体素:分类体素采用线程-体素一对一的方式进行并行计算,目的是求出每个体素的活跃状态。在该核函数中,首先根据场函数计算出所有体素的顶点的值,一一与iso值比对,使用顶点表来查出每个体素中顶点的数量,存入全局数组array1,数量不为0的体素标记为活跃体素,存入全局数组array2。
- 扫描array1与array2:使用Exclusive Scan算法可以求得最终顶点数和活跃体素数,以方便对结果输出数组的内存分配及压缩数组的内存分配。只需将扫描后的数组的最后一项分别与输入数组的最后一项相加。
- 压缩体素:对所有体素中,标记为活跃的体素提取,剔除掉空体素。
- 等值面提取:对活跃体素执行Marching Cubes算法提取等值面上的所有顶点。
- 生成网格:将所有顶点构造生成网格。
不同的场函数
我们可以在算法中使用不同的场函数来构造出不同的效果,最经典的场函数应该是Gyroid,公式为:
图22、图23是笔者在大二时对不同场函数生成形体的尝试,图15是笔者大二时利用Marching Cubes算法设计的3D打印首饰,图16为笔者大二时使用Marching Cubes算法及映射来尝试的形体设计探索。
除了使用场函数以外,还可以使用噪波生成值,Ziyang Wen在他的推送里列举了他使用噪波来生成各种MarchingCubes图形的例子。
Metaballs
我们了解了Marching Cubes,明白了这个算法其实就是给定一个场函数,然后从体素中提取出网格面的算法,而最关键最核心的是后者,因为前者仅仅只是为每个体素上的顶点进行赋值而已。因此在很多算法的开发中,都将Marching Cubes仅作为一个从最后一步生成模型的子算法,比如Metaballs。
输入:点集,iso值
输出:网格模型
Metaballs技术是由Blinn于1982年开发一种适用于建立可变形表面的技术。此技术利用Metaball建立能量场,然后通过标量域的等势面来建立3D模型来表现Soft Objects[11]或者隐式曲面。这项技术常用于实现各种视觉效果
它的本质其实就是将Marching Cubes中的场函数变为距离场,对于体素顶点,根据距离场计算每个输入点得出的值为:
算法步骤如下:
- 输入点集
- 计算所有点集的Bounding Box
- 对Bounding Box划分出体素
- 计算体素上的每个顶点的场值
- 使用Marching Cubes算法提取等值面
然而在实际使用Metaballs的过程中,如果Metaballs的数量过多,并且对其有一定精度要求,会导致计算量非常巨大,可以想象,每个体素的每个顶点都要计算点集数量次距离计算,暴力计算产生的结果是灾难级的。那么我们可以采取一些方法来减少距离计算的次数。
[12]提出了更高效的距离计算方式,公式为:
其中,为该metaball的缩放比例,为控制点生效的最大距离。
笔者也使用CUDA实现了Metaballs算法,github地址:https://github.com/AlbertLiDesign/ALG_MetaBalls3D
参考资料
[1] Dyken, C., Ziegler, G., Theobalt, C., & Seidel, H. P. (2008, December). High‐speed marching cubes using histopyramids. In Computer Graphics Forum (Vol. 27, No. 8, pp. 2028-2039). Oxford, UK: Blackwell Publishing Ltd.
[2] Congote, J., Moreno, A., Barandiaran, I., Barandiaran, J., Posada, J., & Ruiz, O. (2010). Marching cubes in an unsigned distance field for surface reconstruction from unorganized point sets. In Proceedings of the International Conference on Computer Graphics Theory and Applications, vol. 1, pp. 143,147, 2010.
[3] Lorensen W E, Cline H E. Marching cubes: A high resolution 3D surface construction algorithm. ACM SIGGRAPH Computer Graphics. 1987;21(4)
[4] C. Dyken, G. Ziegler, C. Theobalt, and H.-P. Seidel. High-speed Marching Cubes using HistoPyramids. Computer Graphics Forum, 27(8):2028–2039, Dec. 2008.
[5] The algorithm and lookup tables by Paul Bourke httppaulbourke.netgeometrypolygonise:http://paulbourke.net/geometry/polygonise/
[6] Marching Cubes implementation using OpenCL and OpenGL:https://www.eriksmistad.no/marching-cubes-implementation-using-opencl-and-opengl/
[7] A sample extracts a geometric isosurface from a volume dataset using the marching cubes algorithm.: https://github.com/tpn/cuda-samples/tree/master/v10.2/2_Graphics/marchingCubes
[8] Marching Cubes introduction: http://www.cs.carleton.edu/cs_comps/0405/shape/marching_cubes.html
[9] Marching Cubes introduction: https://medium.com/zeg-ai/voxel-to-mesh-conversion-marching-cube-algorithm-43dbb0801359
[10] Lorensen W E, Cline H E. Marching cubes: A high resolution 3D surface construction algorithm[J]. ACM SIGGRAPH Computer Graphics, 1987, 21(4):163-169.
[11] McPheeters, G. W. C., & Wyvill, B. (1986). Data structure for soft objects. The Visual Computer, 2(4), 227-234.
[12] Triquet, F., Meseure, P., & Chaillou, C. (2001). Fast polygonization of implicit surfaces.