多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
欢迎来到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课程中最难的部分,我希望至少能和源课程讲的一样明白。