图形学编程的几个基本问题包括实现颜色、实现纹理、实现光照、实现混合(即透明效果);这几个问题实际上是一个问题:确定图元(像素)的在屏幕上的颜色。
确定图元颜色的过程在顶点着色器和片元着色器中进行:为每一个顶点(注意这里立方体有24个顶点而不是8个)指定一种颜色(并线形内插到每个像元上)以实现颜色;为每个顶点指定从纹理中取色的坐标(并线形内插到每个像元上)以实现纹理;为每个顶点指定光线强度和方向,以此计算出每个顶点的光照影响权值(并线形内插到每个像元上)以实现光照。
这篇博文补充了如何从图片文件中加载纹理、总结了WebGL中帧的绘制过程,其中涉及到深度检测和混合两种绘制方法(即帧上已有像素时的处理方法)。这篇博文内容比较杂,应该也会比较短,算是对前两篇的补充吧。
纹理加载
通常情况下,纹理是一张图片,而纹理对象是一个Javascript对象,类似于缓冲区,由gl对象的方法创建。将纹理加载到纹理对象的代码如下:
var theTexture = gl.createTexture(); theTexture.image = new Image(); theTexture.image.onload = function() { handleLoadedTexture(theTexture)}; theTexture.image.src = "texture.jpg";
gl.createTexture函数返回一个新创建的空纹理对象,将这个空纹理对象传递给handleLoadedTexture函数,这个函数将在Image对象加载完成后执行。注意这个Image对象并不必须是纹理对象的属性,这里这样做只是为了方便。你完全可以重写handleLoadedTexture函数,传入两个参数theTexture和theImage,或者干脆不写这个函数,直接在theImage.onload事件中编写实际工作的代码。handledLoadedTexture函数的代码如下:
function handleLoadedTexture(texture) { gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texture.image); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST); gl.generateMipmap(gl.TEXTURE_2D); gl.bindTexture(gl.TEXTURE_2D, null); }
首先指定纹理坐标垂直翻转,这是因为图片坐标以图片左上角作为原点,Y轴垂直向下,而在WebGL中Y轴是垂直向上的。然后需要将纹理对象绑定到“当前对象”,正和缓冲区一样。再然后用texImage2D函数将Image对象上传到显卡中。最后还需要指定纹理过滤方式,比如这里将图像放大时的过滤方式指定为双线性内插(linear);纹理缩小时的纹理过滤方式指定为金字塔图层方式(Linear MipMaps Nearest)。
纹理图片,也是由离散的像素组成,不可能还原表面的每一个细节。假设一张11×11的图片,纹理坐标在[0,0]之间[1,1]:我可能会希望获取[0.3, 0.5]的颜色值,那自然是[4][6]像素的值;但我几乎一定会获取[0.12, 0.25]这样的颜色值,这个坐标不完全对应于纹理图片中的像素,如何决定应该返回怎样的颜色值的方法,就是纹理过滤方式。
有必要解释一下几种常用的过滤方式
最邻近法:选取离需求纹理坐标最近的像素的颜色值作为返回颜色值。
双线性内插法:选取需求纹理坐标周围的4个点,根据这4个点的颜色值线形内插出一个颜色值返回。
金字塔图层:金字塔图层适合纹理图片较大像素较多,但是三维空间中纹理所覆盖的平面较小像素较少的情况。这种情况下“取色”往往希望得到纹理图片中多个像元的综合信息,而不是某一个或某几个像元。金字塔图层针对不同的尺度预先计算出不同分辨率的图层,当纹理覆盖的表面缩小时,就是用这些计算好的图层代替原纹理取值。
在使用纹理(drawElements函数)之前,还应当激活纹理,并将纹理的编号上传到着色器中的sample2D对象中。WebGL能够维护至多32个纹理,其编号为0-31,你应该记得在texImage2D方法中,我们将纹理上传为0号纹理。在真正的三维编程中,你也许会需要维护一个纹理序列。
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, theTexture); gl.uniform1i(shaderProgram.samplerUniform, 0);
关于纹理,最后还应当注意一点,图片文件加载到对象的过程是异步进行的。也就是说,很可能你的整个html文档和脚本全部加载完成了,纹理文件还没有加载完成。这种情况下,你应当采取tick函数不断地刷新canvas区域,即使你仅仅想绘制一个静态的3D场景。如果你仅在body标签的onload方法里绘制一次canvas区域,很可能这时候纹理还没有加载完成,所有视图贴纹理的表面都是黑色。
帧
根据计算机的性能,浏览器每隔一段时间发出一个刷新canvas区域的请求。在两次刷新canvas区域的短暂时间段内,该区域所呈现的图像称为“帧”。
function tick() { requestAnimFrame(tick); drawScene(); animate(); }
requestAnimFrame将不同浏览器刷新canvas区域的请求封装起来,我只需要知道每隔一个很小的时间间隔,tick函数就会执行一次。animate方法负责改变控制空间中物体和相机运动的变量。drawScene函数绘制完整的一帧。
空间中存在多个物体时,drawScene函数中需要进行多次gl.drawElements方法、gl.drawArrays方法或其他类似作用的方法,每一次绘制都是针对像素。比如,可能会在场景中先绘制一个金字塔,再绘制一个立方体。
function drawScene(){ …… gl.drawArrays(gl.TRIANGLES, 0, pyramidVertexPositionBuffer.numItems); …… gl.drawElements(gl.TRIANGLES, cubeVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0); }
这样,在绘制立方体的时候,很可能会处理已经绘制为金字塔的像素了,这表明两个物体“重叠”了,一个在前一个在后,如果他们不是透明的,那么前面的物体会挡住视线,从屏幕上看不到后面的物体(的一部分)。事实上,在单一的绘制的过程中,也很有可能会处理已处理过的像素:比如绘制金字塔的函数gl.drawArrays(args),需要绘制4个三角形和1个正方形,4个三角面一定会有面在后方被遮挡。
绘制过程中如何对已绘制的像素进行处理,这里介绍两种方式,就是深度检测和混合。
深度检测
深度检测是处理已绘制像素的一种方式。齐次坐标的第三个分量,z值在投影变换中保持了单调性:空间中点的z值较大的,映射到CCV中的z值也较大。z值经过图元光栅化,线形内插到对应于每个像素上。相机向着z负半轴方向观察,因此z值小的在前方,会遮挡相机的实现。
某个已经处理过的像素(x,y,zd,1),颜色为(Rd,Gd,Bd,1),将要绘制上去的像素(x,y,zs,1)的颜色为(Rs,Gs,Bs,1),最终绘制的颜色为(R,G,B,1),则深度检测的过程可以这样表示:
$$if(z_{d}>z_{s})\begin{bmatrix}R\\ G\\ B\\ 1\end{bmatrix}=\begin{bmatrix}R_{s}\\ G_{s}\\ B_{s}\\ 1\end{bmatrix},z_{d}=z_{s}$$
深度监测将z值较小的像素颜色作为最终绘制的颜色,显然,前方的物体将后方的物体挡住了。除非需要绘制透明物体,一般都会开启深度检测。开启和关闭深度检测的代码是:
gl.enable(gl.DEPTH_TEST); gl.disable(gl.DEPTH_TEST);
混合
混合是用以模拟透明效果的一种方法。类似于深度检测,对某个已经处理过的像素(x,y,zd,1),颜色为(Rd,Gd,Bd,ahpad),将要绘制上去的像素(x,y,zs,1)的颜色为(Rs,Gs,Bs,aphas),最终绘制的颜色为(R,G,B,1),混合的过程可以这样表示。
$$\begin{bmatrix}R\\ G\\ B\\ \alpha\end{bmatrix}=\begin{bmatrix}R_{s}\\ G_{s}\\ B_{s}\\ \alpha_{s}\end{bmatrix} \cdot S_{factor}+\begin{bmatrix}R_{d}\\ G_{d}\\ B_{d}\\ \alpha_{d}\end{bmatrix}\cdot D_{factor}$$
其中Sfactor和Dfactor可以通过gl.blendFunc方法指定。比如,将Sfactor指定为待绘制颜色的alpha值,Dfactor指定为1。
gl.blendFunc(gl.SRC_ALPHA, gl.ONE); gl.enable(gl.BLEND); gl.disable(gl.DEPTH_TEST);
混合可以实现类似于透明的效果。开启混合的时候必须关闭深度检测,因为深度检测优先于混合,换言之如果同时开启了混合和深度检测,待绘制的像素z值大于已绘制的z值时,就会直接不绘制,而待绘制z值小于已绘制z值时会正常的混合。
其他
本篇博文中的代码全部来自HiWebGL站点翻译的WebGL教程,对代码的解释是我自己的理解。因为我也是初学WebGL,所以我的理解几乎一定会有错误,如果你发现了,恳请你指出。