通常而言,描边是为了增加对比,将物体与背景更强烈地隔离开。对于我们在上篇文章中实现的卡通渲染而言,物体渐变的颜色被设为几种阶梯式改变的颜色,这也是为了增加对比性。因而在这样的渲染效果中,添加描边可以让对比来得更加强烈。
遍历模型中的每个三角形:
计算三角形是正面还是反面
遍历模型中的每条边:
如果某条边既包含在正面的三角形中,又包含在反面的三角形中:
将这条边作为描边绘制
需要注意的是,以上过程是在 CPU 中计算的(而不是着色器),该算法可以使用非常基本的 OpenGL 操作实现。
那么,如何“计算三角形是正面还是反面”呢?
前向面与后向面
上面我们说的“前面”和“反面”,是较为通俗易懂的说法,而它们严格的名称为:前向面(front face)与后向面(back face)。检测一个面是前向面还是后向面,是非常经典的图形学问题。这里,我还是用比较方便大家了解的方式介绍。
图片来源:《计算机图形学(第三版)》,电子工业出版社,第 109 页
我们高中的时候学过,在笛卡尔坐标系下,任何一个平面可以描述为 Ax + By + Cz + D = 0
,而向量 (A, B, C)
正好是该平面的一个法向量。
推导过程我这里就不展开了,大家可以很方便地这样记住:
如果将 D
设为 0 让平面通过原点,那么原点到平面上的任意一点 (x, y, z)
所形成的向量 (x, y, z)
必然垂直于法向量 (A, B, C)
(因为如果一条直线垂直于一个平面,则它垂直于平面上任何一条直线)。这时候的平面方程 Ax + By + Cz = 0
正好是向量 (x, y, z)
与 (A, B, C)
点乘的结果。而我们知道,如果两个向量垂直,他们的点积为 0。这是一个方便记忆的方式。
当我们要计算一个凸多边形(在我们的例子中就是一个三角形)所在的平面时,我们只要知道其法向量就行了(这里 D
对朝向没有影响,所以可以不管)。
那么,知道一个三角形的三个顶点,如何计算其法向量呢?
还是高中数学问题。答案是——
N = (V2 - V1) ✕ (V3 - V2)
其中,N
是法向量,V1
、V2
、V3
是三角形的三个顶点的位置,✕
表示叉乘。需要注意的是,叉乘是有方向的区别的,在 OpenGL 中绘制三角形输入的三个顶点的顺序决定了面片的朝向,因而写成 (V2 - V1) ✕ (V2 - V3)
的话,会得到相反的方向。
而一旦我们计算出法向量之后,就很容易判断出前后面了。
图片来源:《计算机图形学(第三版)》,电子工业出版社,第 432 页
如果这张图,我们可以很容易地理解,当法向量 N
与观察方向 Vview
点乘大于 0(也就是说法向量在观察方向上的投影是正的),它对观察者来说,就是位于反面的,也就是一个后向面。
明白了前后向面的判定算法之后,这一算法是很好理解,也是很容易实现的。
该算法的优点在于,由于是用 OpenGL 画线的方式实现的描边,因而线宽是可控而且等宽的。
缺点在于,这些操作都是在 CPU 中完成的,有很多点乘叉乘操作,性能并不会很好。并且,它最终的绘制结果如下图 (d) 所示,加粗的黑线表示描边。可以看到,只有周围一圈的轮廓线(silhouette)被描绘了,而一些其他的边缘则被忽视了。
在分析优劣的时候,我们要明白,优劣的区别常常取决于应用场景。比如,在有的情况下,等宽的描边是优点,在另一些情况下,则可能不是。甚至性能也不总是越快的越好,有时候较慢的算法已经超出了可察觉的范围,更高的性能带来的增益是可以忽略的。所以,理解各个算法的特性,对在特定场景下的选择有很大帮助。
沿法向量放大
这一算法解释起来就容易得多了。它的核心思想是,将每个顶点沿法线方向延伸(如图 b),然后填充放大的模型(如图 c,并且通常只填充后向面),在此基础上,再绘制原始模型叠加上去(如图 d)。
这一算法可以在着色器中实现,但是需要两次渲染。第一次在顶点着色器中延伸顶点,并将顶点颜色设为描边的颜色。第二次正常渲染,并且渲染前不要清除已经绘制的结果。
该算法可以在着色器中实现,效率较高,实现方式也比前一种直观方便。
缺点是,和第一种算法一样,只有前后面交界处才会被描边;每帧需要渲染两次;描边粗细不完全是相同的。
为什么可能粗细不同呢?让我们通过一个更简单的例子来理解一下。
假设我们的原始模型是图 (a) 的直角三角形,三个顶点沿法线方向延伸相同的长度,得到图 (c) 的结果。可以发现,不仅图 (b) 代表的描边粗细是不相等的,而且放大后的模型都失去了原有的直角属性。
所以,使用这种方法描边,得到的边缘可能是变形的。
使用图像处理的方式,可以将法向量或深度缓冲区绘制到一张纹理,然后通过边缘检测算法,得到画面中法向量和深度突变的地方,通常这些可以作为边缘描绘。
上图展示了深度缓冲区的大小,对应的片元着色器是:
void main()
float zbuffer = gl_FragCoord.z * gl_FragCoord.w * 100.0;
gl_FragColor = vec4(zbuffer, zbuffer, zbuffer, 1.0);
其中,gl_FragCoord.z
是深度信息,gl_FragCoord.w
是缩放因子,乘以 100.0
是为了将深度信息缩放到一个合适的颜色显示,通常需要根据场景的深度进行试验得到。
用 Canny 边缘检测算法,能够得到一个非常理想的边缘——
这一算法的效果优劣,主要取决于边缘检测算法。而如果模型比较复杂的情况下,可能就没有上图这么好的结果。
通过边缘检测获得的边缘,具有很大的不确定性,有可能得到噪点很多或者没有检测到边缘的情况。如果边缘检测能够在着色器中做的话,效率会更高些。
平行屏幕方向的法向量
下面要介绍的这个算法,就是我们实际应用到塞尚项目中的。为什么用这种算法呢?因为它只需要修改着色器就能实现效果,实现起来是最方便的,所以我就偷懒只实现了这个效果。(但是前面几种算法的示意图画得超清楚有木有!)
这一算法的思想是,在片元着色器中,根据视觉坐标系下的法向量,找到平行屏幕的片元,作为边缘。片元着色器代码如下:
varying vec3 vNormal;
void main() {
float silhouette = length(vNormal * vec3(0.0, 0.0, 1.0));
if (silhouette < 0.5) {
silhouette = 0.0;
else {
silhouette = 1.0;
gl_FragColor = vec4(silhouette, silhouette, silhouette, 1.0);
其中,vNormal
是顶点着色器中传递过来的法向量(在上一篇教程中有介绍);vec3(0.0, 0.0, 1.0)
是垂直于屏幕的方向,也就是视图坐标系下的视角方向。vNormal * vec3(0.0, 0.0, 1.0)
是将法向量和视角方向进行点乘,得到法向量在视角方向上的投影。length()
得到该点乘结果的模长,如果它较小,代表法向量在视角方向上的投影较小,也就是法向量较接近于平行屏幕的方向。
得到的效果如下:
使用这种方法,能够得到第一、第二种算法所忽视的不在前后面交界处的边,这有时是比较有用的。它的原理和实现都比较简单,能够在着色器中高效地计算。
缺点在于,需要指定一个阈值,然而对于不同场景,可能都需要调节这个阈值以达到更好的渲染效果,因而就有些不缺点性。并且,就像上面苹果的渲染结果显示的那样,有时候平行屏幕的法向量,并不意味着边缘,因而会在梗的根本有一些不太理想的边缘。
至于这种算法形成的粗细不同的描边,和卡通渲染的效果结合起来,倒也是蛮搭的呢!
结合卡通渲染
结合的方法是,如果一个片元是边缘,则按边缘色渲染,否则渲染卡通渲染的结果。
顶点着色器代码:
uniform vec3 color;
uniform vec3 light;
varying vec3 vColor;
varying vec3 vNormal;
varying vec3 vLight;
void main()
// pass to fs
vColor = color;
vNormal = normalize(normalMatrix * normal);
vec4 viewLight = viewMatrix * vec4(light, 1.0);
vLight = viewLight.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
片元着色器代码:
varying vec3 vColor;
varying vec3 vNormal;
varying vec3 vLight;
void main() {
float silhouette = length(vNormal * vec3(0.0, 0.0, 1.0));
if (silhouette < 0.5) {
silhouette = 0.0;
else {
silhouette = 1.0;
float diffuse = dot(normalize(vLight), vNormal);
if (diffuse > 0.8) {
diffuse = 1.0;
else if (diffuse > 0.5) {
diffuse = 0.6;
else if (diffuse > 0.2) {
diffuse = 0.4;
else {
diffuse = 0.2;
diffuse = diffuse * silhouette;
gl_FragColor = vec4(vColor * diffuse, 1.0);
最终得到的结果是:
这篇博客介绍了如何实现描边算法。总体而言,通过判断面片朝向和沿法向量放大的方法较为稳定,但是不能得到除了轮廓线之外的边缘;而基于图像处理和法向量方向的方法具有一些不确定性,但是能够得到轮廓线之外的边缘,并且通常来说,计算效率更高。
对于具体的应用场景,可以根据各自的优劣选择合适的算法。
另外,因为写这一系列教程,让我也温故知新了很多图形学知识。而把这个项目取名为“塞尚”,也是希望能够坚持写下去,把一件简单的事做细做明白,谢谢大家的支持!
本文实现的结果可以在 zhangwenli.com/cezanne 运行,或者在 GitHub 查看源码。
《计算机图形学》:这是一本介绍非常底层的图形学实现原理的书,可能很大情况下你并不需要了解这些细节,但是如果感兴趣的话,这本书会帮你解答很多疑惑。
how to show silhouette using GLSL
WebGL+shader实现素描效果渲染
Follow Wenli Zhang at