叶片轮廓提取代码_勾勒3D物体的轮廓

  在2D窗口界面,控件会有多种状态—— hover、focused、pressed、selected 等。在3D建模软件或游戏里,场景中的模型一般有正常、选中两种状态。我们用指定颜色的轮廓来标记物体被选中状态。编辑模式下,对选中的物体、点、线、面可以用选中的颜色绘制。但是物体的轮廓,需要根据相机的朝向来动态计算,没有那么简单。

  轮廓对应好几个英文单词——silhouette、outline、profile、contour,它们之间有着细微的差别。下面给出了多种方法画轮廓,画的不一定是最外层的一圈。

  1. 缩放物体
  2. glLineWidth
  3. glDepthFunc
  4. G-buffer + 图像边缘检测
  5. 顶点法向量与视线垂直处,视为轮廓
  6. geometry shader

  以下假定待绘制模型或网格的函数是 void render(const mat4f& viewProjection);,采用OpenGL 3.3着色语言。轮廓颜色为 vec4f selectedColor,渲染轮廓不会涉及到复杂的光照计算。构成轮廓的线段在绘制也不需要开启颜色插值,可以添加 flat 修饰符。这是潜在的优化措施,默认情况是透视矫正插值,noperspective 指定线型插值,flat则不插值。

方法1. 缩放物体

  启用模板,在 render() 一次之后,稍稍放大一下模型,以 selectedColor 再次 render() 一遍。在第二次绘制的时候,仅对没有覆盖到的模板区域进行绘制。这里有详细的代码和说明,对应的也有中文版翻译。

  放大模型又分三种做法:其一,根据远小近大的思想,给模型矩阵(model matrix)加上平移变换,让渲染的物体沿着模型的中心坐标与眼睛连线方向上平移靠近,不过这样渲染出来的轮廓无法保证厚度一致;其二,沿着物体中心放大,对立方体或球这样的简单物体还行,对凹陷或者有洞的物体表现就糟糕了(可以自己在纸上画画2D情况帮忙理解);其三,沿着各顶点的法向量方向缩放,相当于给物体裹上一层外壳,具体的 vertex shader 如下:

#version 330

  thickness为正值时,放大可画外轮廓;thickness为负值时,缩小可画内轮廓。但是问题也是有的,该方法擅长处理光滑的曲面,对于硬边(hard edge,3D建模术语,表面的法向量变化不连续,即一个顶点处对应着多个法向量,对应 Wavefront .OBJ 文件的 smoothing group (s)条目)处理是硬伤。想象一下立方体就懂。

  矩阵连乘,我都有加括号的习惯,让矩阵先跟向量结合。GLSL 里我会写 gl_Position = projection * (view * (model * position)),而不是 gl_Position = projection * view * model * position,算是一点优化措施吧。因为 HLSL 的矩阵用的左乘,相当于 dx_Position = position * model * view * projection,乘法是左结合的,所以没有这个顾虑,不用加括号。矩阵连乘的最优解一般会作为动态规划算法的例子讲。

方法2. glLineWidth

  启用蒙板(stencil),在render()一遍三角面之后,用更粗的线条,以 selectedColor 再次 render()一遍线框(wireframe)。在第二次绘制的时候,仅对没有覆盖到的蒙板区域进行绘制。相当于2D图像处理中的描边操作。关键代码如下:

glEnable

  我们只是勾勒出轮廓,所以不会要求多大的线宽。这里还要提一下, OpenGL 对线宽有一个支持范围,查询用 glGet*() 函数。

GLfloat 

以上方法对 OpenGL 还算行,对 Direct3D,甚至 OpenGL ES 则力所不逮。OpenGL ES 规范中仅要求支持线宽1,其他数据不作要求。默认情况下,点为一个像素,直线是一个像素的宽度,然后一般的图形学API只懂得画三角面。这时,可以利用 geometry shader 将线框数据生成有宽度的面数据来绘制。

  方法0和方法1都是 render() 了两次,在性能上会有大的开销,但前者第二次画的三角面,后者第二次画的线框,少了面内插值计算,理论上性能会有提升。

方法3. glDepthFunc

  深度缓冲区(depth buffer)是硬件都会支持的,不会有上面方法的顾虑。调用 glDepthFunc(GL_LESS); 可以获取物体投射后最近的深度,调用 glDepthFunc(GL_GREATER); 可以获取物体投射后最远的深度。在相机发出的射线与物体的边缘相切时,只存在一个深度,没有远近深度。这个深度所对应的像素,就是我们要找的轮廓。

glEnable

方法4. G-buffer + 图像边缘检测

  延迟渲染(deferred rendering)都会用到 G-buffer(Geometry buffer),G-buffer 将场景的几何数据渲染到纹理上供后期使用。我们这里用到的几何数据有法向量、深度、物体ID,只要是法向量或深度不连续变化的地方,就是轮廓。怎么衡量不连续变化呢?下面会提到边缘检测。为什么要记录物体ID呢?因为场景中可能存在多个物体,物体之间有遮挡关系,我们希望勾勒出选中物体的轮廓而不是所有物体的轮廓。

  先来一遍render(),然后在2D图像层面上做边缘检测。边缘提取可以看成是一种滤波,用不同的算子会得到不同的提取效果。比较常用的有三种,Sobel算子,Laplace 算子,Canny算子。找到边缘后,我们还可以使用形态学的膨胀或腐蚀操作,来加粗或者细化轮廓线。

方法5. 顶点法向量与视线垂直处,视为轮廓

  想象一下球,表面法线四处散开,其与视线方向垂直的一圈构成轮廓。两向量垂直,点乘积为0,我们可以用接近0的小数,来控制轮廓的粗细。下面的图是我用代码生成的一张 matcapMaterial Capture)贴图,从中央的黑色到指定的边缘颜色过渡,像3D渲染出来的球一样,为自己鼓掌!

239625865c1b4b6a04689175d9012499.png
matcap

  我们的场景只会用到两种颜色,正常色和边缘色,不存在过渡。这个方法的优点是,只用渲染一道。

vertex shader 如下:

#version 330

fragment shader 如下:

#version 330

  顶点坐标的变换矩阵是 model,法向量的变换矩阵是 model 的逆的转置,转置的逆也一样。(数学推导见这里,或者翻看红宝书 OpenGL Programming Guide 后面的附录Homogeneous Coordinates and Transformation Matrices。)用 GLSL 内置的函数表示就是 transpose(inverse(mat3(model)))。因为矩阵的阶数越小越容易求解,而法向量都是3维的,所以这里用了 mat3(model),表示取矩阵的左上角。

  那为什么往下一行对法向量的变换就可以直接乘以 mat3(view) 呢?因为对于 view 矩阵,三个轴 right/forward/up 是单位正交基,正交单位矩阵的逆等于该矩阵的转置,逆的转置操作也就相当于转置了两次,等于没变,所以可以直接乘。物体缩放后,model 矩阵无法确保这个条件。

  该方法很可能画出多个轮廓线,比如从侧面看人头的轮廓会画出耳朵的轮廓,又比如从瓶口朝瓶底看葫芦会画出多圈轮廓。可以启用深度测试,过滤掉背后的轮廓线。

方法6. geometry shader

  假设你的硬件支持 geometry shader,并且你也熟悉 geometry shader 的使用。忽略tessellation 阶段,geometry shader 对 vertex shader 传过来的图元(primitive)裁减或生成同类或其他类型的图元。

  思路是这样的:利用三角面的邻接(adjacency)信息,判断边张成的二面角,在视图坐标系(乘以view matrix)下,其法向量一个朝屏幕内,一个朝屏幕外,那么就找到物体的一段边缘,可以用selectedColor 来 EmitVertex() 了。当然,也可以在投影坐标系(再乘以projection matrix)下,2D 平面上的三角形用叉乘后的符号判断朝里还是朝外。

  往细分,又是两种做法——lines_adjacency 和 triangles_adjacency。前者利用边被两个三角形共享,后者利用三角形周围有三个邻接三角形。假设3D物体的顶点数为V,边数为E,面数为F,lines_adjacency 需要传 4E 个顶点索引,triangles_adjacency 需要传 6F 个顶点索引,根据下面推导关系来看,索引数据量一致,只能做实验对比两者的性能。下面讲的是 lines_adjacency 的做法,triangles_adjacency 的做法在这里。顺便说一下,该链接也收录在 OpenGL 4.0 Shading Language Cookbook 书中。

  假设我们的多面体物体都是三角面,则有关系

,所有三角面的边数量等同于每个边累积了两次。根据与球面同胚的多面体
欧拉公式
,若物体贯穿有洞,比如轮胎、马克杯等,一个洞算一个亏格(genus),需要修改公式为
。等号右边的数字相对模型 V, E, F 数据来讲很小,可丢掉,近似为
。两式联立得到关系

// vertex shader 如下

#version 330

// geometry shader 如下

#version 330

// fragment shader 如下

#version 330

uniform vec4 selectedColor;

out vec4 fragColor;

void main
{
	fragColor = selectedColor;
}

  为了走上面的渲染流水管线,我们需要从提供的物体网格数据提取 linesAdjacency 数据,即每一条边

被两个三角形共享的顶点
,图见上面代码注释。如果不是闭合的曲面的话,会出现有的边只被一个三角形使用(特别地,我们的物体就只有一个三角形),这种情况可以看成
两点重合输送。毕竟,物体的任何一条边都有可能是轮廓的一部分。从网格提取邻接边、面等信息,可以用
半边数据结构(Half-Edge Table),几何算法库CGAL也会提到。
std

  上面的 draw call 用的是 glDrawElements 而不是 glDrawArrays。索引相比顶点数据而言,更节省内存和带宽。用顶点数据,需要渲染 3F 个顶点,每个顶点占 sizeof(vec3) = 12 字节,共 36F 个字节。用索引数据,需要渲染 V 个顶点,3F 个索引,索引用 uint32_t 类型(实际则可能缩减成 uint16_t,甚至 uint8_t),共占用 12V + 4 * 3F = 12(V + F) 字节。利用前面的推导

,前者72F,后者 36F,差不多省去一半。而且重用顶点数据,使用索引的地方越多,效果会越好。

  上面5和6两种方法,原理差不多,只是前者是以solid模式画,后者是以wireframe模式画。前者要在每个fragment上计算法向量,计算量大但是 GPU 最擅长做这种事;后者在前期准备工作较多,只能在CPU上做。有一些优化方案,考虑到轮廓线一般是闭合的曲线,所以在搜索过程中,找到一小段轮廓,顺蔓摸瓜,利用半表数据结构查邻接边,拽出整个轮廓。因为物体凹凸不一,就可能存在多条闭合纹路,最外一圈才算轮廓。

Blender

2c59595f53e11ad8fe6f1718b3ed8c40.png
Blender软件。中间选中的猴头轮廓为黄色,两边的猴头轮廓为绿色。

  如果已有开源的软件实现了这个功能,有现成的代码,为什么不研究一下呢?Blender 2.80+ 版本基于OpenGL 3.3 开发。勾选 3D viewport 右上角 viewport shading 里的 outline 复选框,调整颜色为绿色,则所有物体的轮廓会显示出来,选中中间一个猴头,得到黄色的轮廓,这就是选中的状态。

  关于 overlay 的选项,在目录 <blender>/source/blender/draw/engines/overlay/,这个目录的 shader 文件夹下,放着大量的 .glsl 文件。其中 outline_*.glsl 是我们要关注的。 outline_prepass_vert.glsl
outline_prepass_geom.glsl
outline_prepass_frag.glsl
outline_detect_frag.glsl
  还有几个 outline 着色器,是专为骨架(armature)定制的。骨头可以选择在多个形态之间切换。因为每一根骨头的结构是固定的,可以走 instance rendering 提升性能。八面体的骨头,线框模式下的 lines_adjacency 数据bone_octahedral_wire_lines_adjacency,就可以内置,不用动态计算。Blender 用了上面的 geometry shader 方法。

延伸阅读

Real-Time Rendering 一书用了多种方法讲解如何画轮廓,放在 Non-Photorealistic Rendering 这一章。第3版在 11.2 Silhouette Edge Rendering,第4版在 15.2 Outline Rendering。

OpenGL 4.0 Shading Language Cookbook 一书的第6章 Drawing silhouette lines using the geometry shader 节。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值