欢迎来到WebGL系列教程的第8课,基于NeHe OpenGL教程的[第8课](http://nehe.gamedev.net/data/lessons/lesson.asp?lesson=08)编写。本课,我们将会探讨颜色混合【blending】,顺带简单穿插一下深度缓存的工作原理。
以下是demo演示视频(备注:视频并不能完全表现出透明度的效果)
https://youtu.be/sQNv-88BW6Q
[点击这里你能看到样例展示](http://learningwebgl.com/lessons/lesson08/index.html),当然了,如果你的浏览器支持WebGL,你能看到一个缓慢旋转的半透明立方体,看上去是用雕花的玻璃制成。而场景中的光照和上节课是一样的。
你可以使用画布下方的复选框切换颜色混合开关,来改变透明效果。你也可以调整alpha级别因子(我们稍后会解释这个)看看效果。同样的,你也可以改表各种光照参数。
下面讲解它是如何工作的...
>[info] 惯例声明:本课程面向具有一定编程基础,但在3D图形学方面无实际经验人群,目标是使学习者运行并了解WebGL代码,以便快速构造自己的3D页面。如果你还没有阅读之前的课程,建议先读完它们 -- 这里只会讲述和之前不同,以及新增的部分
>在教程中难免会有bug和错误,如果你发现了它,请告诉我,我会尽快改正它
本课的源码可以查看:https://github.com/gracefung/webgl-codes/tree/master/lesson08 【这是繁体译文对应的github源码,感觉还不错】
在开始讲解代码之前,还需要介绍一些理论。作为开始,我觉得应该阐述一下颜色混合到底是什么东西,为了解释清楚,恐怕要先讲解深度缓存的一些知识。
**深度缓存【The Depth Buffer】**
当告知WebGL绘制一些东西的时候,回忆下第2课,它会经历一个渲染管线阶段。从顶层开始,将有以下步骤:
1. 运行vertex shader,对每一个顶点进行计算,得到所有顶点的位置。
2. 顶点间使用线性插值,以此来告诉WebGL哪些片段需要渲染,片段在这里你可以理解为像素。
3. 对于每一个片段,运行fragment shader来算出它的颜色。
4. 将片段【fragments】写入到片段缓存【frame buffer】中。**这里可以看出fragment真实含义,其实是关于一个像素的所有信息集合**
可以发现,最终片段缓存存储了要绘制的内容。但如果你要绘制2个东西呢?比如,你要绘制2个同样大小的正方形,第一个中心位置为$$ p_1 = (0, 0, -5) $$,第二个中心位置为$$ p_2 = (0, 0, -10) $$,这种情况下会发生什么呢?你肯定不希望第2个方形画在第1个上面,因为第2个距离摄像机更远,它应该被隐藏才对。
WebGL处理这种情况时,用的就是深度缓存。当片段及RGBA颜色值被fragment shader处理后,写入到片段缓存中时,它同时存储了该片段的一个深度值,这个深度值和该片段Z坐标值相关【related to】,但又不完全一样。因此,**深度缓存又常常被称为Z缓存**【Z buffer】
为什么我说是“相关”呢?因为WebGL通常会将Z值映射到(0,1)的区间中,0表示最近,1表示最远。这个操作在drawScene()函数开始,我们调用透视来创建投影矩阵的时候就已经发生了,所以对我们来说是透明的。现在你需要知道的就是一个物体Z-buffer值越大,那么它就离摄像机越远;这和我们常见的坐标系是相反的【WebGL的Z轴坐标系正方向是朝向摄像机】。
OK,这就是深度缓存。现在,你也许回忆起第一课中,我们初始化WebGL上下文的代码,有这么一行:
~~~
gl.enable(gl.DEPTH_TEST);
~~~
这句话就是告诉WebGL系统,当有一个新的片段写入片段缓存时应该怎么做。基本上意思就是“考虑深度缓存”。它通常和另一个WebGL设置结合使用:深度函数。这个函数本身是有一个合适的默认值,但如果我们显式设置它为默认值时,看起来是这样:
~~~
gl.depthFunc(gl.LESS)
~~~
这意思是“如果我们的片段Z值比当前同位置的Z值小,就使用新的片段,取代旧的”。这个测试是系统内置的,结合上面的代码来启用它后,就能够提供给我们合理的表现;近处的物体遮挡住远处的物体(你也可以用其它不同的值来设置深度函数,但我觉得它们出场率极低)
**颜色混合【Blending】**
颜色混合是这一过程的另一选择。通过深度测试,我们使用深度函数来决定是否用新片段来替换旧片段。而当我们做颜色混合,我们使用一个混合函数,将已存在的片段颜色,和新片段的颜色结合起来,生成一个全新的片段,然后将其写入到片段缓存中。
现在我们来看看代码。大部分都是和第7课一样,并且重要的代码基本上都在*drawScene()* 函数中,且代码量很小。首先, 我们检查“blending”复选框是否被勾选上:
~~~
var blending = document.getElementById("blending").checked;
~~~
如果被勾选,我们将混合函数设置为结合2个片段颜色:
~~~
if (blending) {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
~~~
混合函数中的参数指定了混合的方式。这是一个费时的操作,但并不困难。首先,让我们定义2个术语:我们当前正在绘制的片段称为**源片段**,已经存在于片段缓存中的称为**目标片段**。*gl.blendFunc()* 函数的第1个参数决定了**源因子【source factor】**,第2个参数决定了**目标因子【destination factor】**,它们都是数字参数。在这个例子中,我们将源因子定义为源片段的alpha值,目标因子则是常量1。当然也有其它的选择。比如,你可以使用SRC_COLOR来表示源片段的颜色,那么最终你将分别得到红,绿,蓝,透明度【alpha】的值作为源因子【可以看出因子可以是多个数】,它们分别等于源片段的RGBA分量值。
现在,让我们想象一下,有了目标片段,其颜色值为$$ c1 = (R_d, G_d, B_d, A_d)$$,也有了源片段,其颜色值为$$ c2 = (R_s, G_s, B_s, A_s)$$。WebGL将要计算出新的片段颜色。
此外,我们假设源因子是$$ f1 = (S_r, S_g, S_b, S_a) $$,目标因子是$$ f2 = (D_r, D_g, D_b, D_a)$$。
对于每一个颜色分量,WebGL会做如下计算:
$$R_{result} = R_s \times S_r + R_d \times D_r$$
$$G_{result} = G_s \times S_g + G_d \times D_g$$
$$B_{result} = B_s \times S_b + B_d \times D_b$$
$$A_{result} = A_s \times S_a + A_d \times D_a$$
所以,在我们的例子中,我们有(为了简单,这里仅给出红色分量的计算):
$$R_{result} = R_s \times A_s + R_d$$
一般情况下,这并不是产生透明的理想方式,但是在这个例子中启用光照的情况下,它恰好表现的很好。有一点是值得强调的:颜色混合并不等价于透明,它只是可以得到透明效果的其中一种技术。在我自己学习Nehe教程时,我花了很久才参悟出这一点,所以请原谅我现在过于强调这一点。
好的,我们继续:
~~~
gl.enable(gl.BLEND);
~~~
一行很简单的代码 -- 就像WebGL很多其它特性一样,颜色混合默认是关闭的,所以我们需要打开它。
~~~
gl.disable(gl.DEPTH_TEST);
~~~
这里有点有趣;我们需要关闭深度测试。如果我们不这样做,颜色混合会在有些地方有效,而有些地方失效。比如,如果我们先绘制立方体的背面,然后绘制正面。那么当背面绘制时,它会被写入片段缓存中,然后正面会在它上面进行混合,这是我们想要的。然而,若我们调换顺序,先画正面再画背面,那么背面就会被深度测试忽略掉,从而到不了混合函数环节,所以它就不会对最终图像产生影响。这不是我们想要的。
敏锐的读者可以从这里,以及上面提到的混合函数注意到,颜色混合强依赖于绘制的顺序,这在前面的课程中并没有遇到。稍后会对此作出更多的讲解。现在先看完下面这行代码:
~~~
gl.uniform1f( shaderProgram.alphaUniform, parseFloat(document.getElementById("alpha").value) );
~~~
这里我们读取页面输入框中的alpha值,传入到shader中。这是因为我们用作纹理的图片自身并没有alpha通道(它只有RGB,所以每个像素的alpha值都是默认的1)。因此自由调节alpha值,能够方便看到它是如果影响图像的。
drawScene()中剩余的代码,是为了禁用颜色混合后,使用正常的方式进行图形处理。
~~~
else {
gl.disable(gl.BLEND);
gl.enable(gl.DEPTH_TEST);
}
~~~
在fragment shader中也有一些小的改动,在处理纹理时用上alpha值:
~~~
precision mediump float;
varying vec2 vTextureCoord;
varying vec3 vLightWeighting;
uniform float uAlpha;
uniform sampler2D uSampler;
void main(void) {
vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a * uAlpha);
}
~~~
这就是fragment shader中所有的改变【就加了个uAlpha uniform】
现在让我们回到绘制顺序这个问题上来。我们在这个例子中得到的透明效果非常好 -- 它看上去真的像雕花玻璃制成。但是改变一下平行光方向,使其来自Z轴反方向 -- 只需要将Z坐标的负号去掉。它看上依然不错,但失去了逼真的“雕花玻璃”效果。
产生这种现象的原因是,在原来的光照处理中,立方体的背面总是光线暗淡的。这就意味着它的RGB分量值都很小,所以当进行如下计算后:
$$R_{result} = R_s \times R_a + R_d$$
它们只是隐约可见。换句话说,当启用了光照,那么物体背面就基本不可见。如果我们调整光线方向,使物体正面基本不可见,那么我们的透明效果就不怎么好了。
那么如何才能得到“合适的”透明度呢?OpenGL FAQ的建议是,你需要使用一个源因子SRC_ALPHA【源片段的alpha值】,和一个目标因子ONE_MINUS_SRC_ALPHA【 1 - SRC_ALPHA 】。但是我们依然会遇到问题,因为源片段和目标片段是区别对待的**【即你一旦提了这2个概念定义,那就是在区别对待这2个事物,那么它们就不能等价替换,所以也就不能调换顺序】**,所以依旧依赖于物体绘制的顺序。这最终牵扯出OpenGL/WebGL中关于透明处理,我认为是不光彩的潜规则的一点。引用一下OpenGL的FAQ:
>[warning] 当在程序中使用深度缓存时,你需要关注渲染图元的绘制顺序。按照由远及近的顺序,完全不透明的图元需要最先被渲染,接着是部分透明的图元。如果你不按照这样的顺序渲染图元,那些本来通过部分透明图元可见的物体,可能会完全无法通过深度测试。
所以,你懂的。使用颜色混合得到的透明,是复杂而繁琐的,但如果你对于场景其它方面控制到位,就像我们本节课中控制光照,那么就不需要多复杂就能得到正确的效果。正确的绘制出物体很容易,但是想让它好看,则需要仔细的按照一个特定的顺序来绘制。
幸运的是,颜色混合对其它效果也有用,就像你将在下节课看到的。但目前,你已经学完了本课的只是:你清楚的了解了深度缓存,知道如何使用颜色混合来实现简单的透明。
对于这节课,我第一次学习时,感觉这是NeHe课程中最难的部分,我希望至少能和源课程讲的一样明白。