OpenGL的教程多以“画一个点”开始:简单的初始化过程后,调用glVertexXX()并传入描述点信息的位置。下面就是一个典型的OpenGL的HelloWorld代码。
glBegin(GL_POINTS); glVertex3f(0.0f, 0.0f, 0.0f); glEnd();
开始学习WebGL的时候我试图寻找这样的代码,之后我发现在WebGL中,即使要画出一个点,也需要了解着色器和缓冲区的知识。好在对于尝试编写WebGL程序的人来说,关于着色器和缓冲区的知识是必要的。在研究了HiWebGL站点翻译的WebGL教程前几课的代码,并且自己尝试实现一个3D贪吃蛇程序之后,我对着色器和缓冲区的知识稍作整理,写下这篇博文,以便以后查阅。如果你也在学习这方面的知识,希望这篇博文能够帮助到你。
着色器
着色器,可以理解为运行在显卡中的指令和数据。
完整的着色器包括顶点着色器和片元着色器。顶点着色器最基本的任务是接收三维空间中点的坐标,将其处理为二维空间中的坐标并输出;片元着色器最基本的任务是对需要处理的屏幕上的每个像素输出一个颜色值;将顶点着色器输出的二维空间中的点坐标,转化为需要处理的像素并传递给片元着色器的过程,称为图元光栅化。
在WebGL中,着色器是用一种类似于C的语言x-shader编写的。
1.顶点着色器
顶点着色器接受attribute变量和uniform变量。attribute变量存储着关于点本身的数据,其中最重要的当然是点的位置。uniform变量存储的数据仅仅帮助着色器完成任务,换言之,着色器仅仅是需要uniform变量而并不处理他们。顶点着色器需要输出varying变量给片元着色器。
注意,attribute、uniform、varying并不是数据类型,而只是描述该变量在着色器中的作用。
最简单的顶点着色器代码如下:
<script id="shader-vs" type="x-shader/x-vertex"> attribute vec3 aVertexPosition; uniform mat4 uMVMatrix; uniform mat4 uPMatrix; void main(void) { gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0); } </script>
定义vec3类型的attribute变量aVertexPosition(这里可以注意一下对变量的命名习惯),这个变量以三维向量的形式存储了点在三维空间中的信息。
定义mat4类型的uniform变量uMVMatrix和uPMatrix,分别表示模型视图矩阵和投影矩阵。
顶点着色器的处理单元是单个顶点。对每一个顶点数据,都要执行一次main(void)函数来返回这个顶点在二维屏幕上的坐标和其他varying变量。准备给顶点着色器处理的attribute变量的数量需要和顶点的数量一致(比如顶点在三维空间中的位置的数量就和顶点的数量一致,很快这种表述就不那么像废话了),而每个uniform变量只需要一个,对每个顶点的处理用到的是同一个uniform变量。
在主函数main(void)中可以看到很清晰的逻辑,即将三维空间中的点映射到屏幕上(实际上是CCV中)。
varying变量是顶点着色器的输出,经过光栅化后作为片元着色器的输入。最简单的着色器没有显式定义varying变量。gl_Position是一个varying变量,由于它非常重要,已经被隐式定义了,也就是说,顶点着色器必须返回gl_Position。
2.片元着色器
片元着色器的唯一任务是,给出屏幕上每个像素的颜色。片元着色器接受varying变量——正是顶点着色器的输出,但是不完全一样。交给片元着色器的处理单元不是顶点,而是像素,将顶点转化为像素的技术称为“图元光栅化”,这稍后再去了解,现在只关心对某一个像素,如何指定它的颜色。
最简单的片元着色器代码如下:
<script id="shader-fs" type="x-shader/x-fragment"> precision mediump float; void main(void) { gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); } </script>
这个片元着色器处理像素的方式果然很简单,那就是:将待处理的像素的颜色指定为不透明的白色(注意是待处理的像素而不是屏幕上的所有像素)。片元着色器只接收了gl_Position这个varying变量,而gl_Position经过光栅化后已经用来指定“要处理的是哪个顶点”这条信息了,因此片元着色器没有关于顶点颜色的任何信息,只能指定所有要处理的像元都是一个信息。
3.图元光栅化
图元光栅化将顶点着色器的输出转化(一系列顶点)为片元着色器的输入(一系列像元)。
顶点着色器处理了一个空间中三角形的数据,得到CCV中的三角形的三个顶点,在图元光栅化发生之前,gl_Position是一个有三个元素的数组,每个元素表示一个顶点的齐次坐标。
$$\begin{bmatrix}x_{1} & x_{2} & x_{3}\\ y_{1} & y_{2} & y_{3}\\ z_{1} & z_{2} & z_{3}\\ 1 & 1 & 1 \end{bmatrix}$$
图元光栅化发生之后,gl_Position成为了具有许多个元素的数组(下图中是16个),每个元素表示一个像素。
$$\begin{bmatrix}x_{1} & ...... & x_{16}\\ y_{1} & ...... & y_{16}\\ z_{1} & ...... & z_{16}\\ 1 & ...... & 1 \end{bmatrix}$$
柱状物表示分割出的需要处理的像素,高度可以认为是z坐标值,由线形内插而成。事实上,除了gl_Position的x、y分量,其他所有varying变量都会进行光栅化,而值都是线形内插得到的。我们可能需要一个varying变量表示顶点的颜色(我们要在javascript中为三个顶点准备三个颜色),光栅化的时候,也会为16个像素线形内插出16个颜色。
线形内插的方法很简单,对于三角形上具有x、y确定坐标的像素点(xp,yp,zp)有:
$$\begin{bmatrix}x_{p}-x_{1}\\ y_{p}-y_{1}\\ z_{p}-z_{1}\end{bmatrix}=a\begin{bmatrix}x_{2}-x_{1}\\ y_{2}-y_{1}\\ z_{2}-z_{1}\end{bmatrix}+b \begin{bmatrix}x_{3}-x_{1}\\ y_{3}-y_{1}\\ z_{3}-z_{1}\end{bmatrix}$$
只有a、b、zp是未知数,因此是很容易解出zp的。
4.简单的实现颜色
顶点着色器代码:
<script id="shader-vs" type="x-shader/x-vertex"> attribute vec3 aVertexPosition; attribute vec4 aVertexColor; uniform mat4 uMVMatrix; uniform mat4 uPMatrix; varying vec4 vColor; void main(void) { gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0); vColor = aVertexColor; } </script>
片元着色器代码:
<script id="shader-fs" type="x-shader/x-fragment"> precision mediump float; varying vec4 vColor; void main(void) { gl_FragColor = vColor; } </script>
最简单的实现颜色的方法是,为每个顶点准备一个颜色。如果希望一个面具有单一的颜色,那么就为这个表面的三个顶点准备同一个颜色。颜色通过新建的attribute变量aVertexColor传入顶点着色器;在处理每个顶点(通过aVertexPosition计算出gl_Position)的同时,处理那个顶点所对应的颜色(通过aVertexColor计算出vColor)。这个例子直接将前者赋值给后者,当然可以有其他的处理方法,比如你想取反色的时候。
片元着色器里的vColor变量已经光栅化过了,也就是说,如果顶点着色器处理的是上文说到的那个三角形,顶点着色器里的vColor还是具有3个元素的数组,到片元着色器里,vColor已经是具有16个元素的数组了。颜色的RGBA值全部类似于gl_Position中的z值一样被线形内插过了。
5.实现纹理
实现纹理和实现简单颜色的不同在于,像素点的颜色不是单纯计算出的,而是从样本纹理上查询得到。每个像素点的颜色都从样本纹理上查询到了,也就是将纹理贴上表面了。
顶点着色器代码
<script id="shader-vs" type="x-shader/x-vertex"> …… attribute vec2 aTextureCoord; varying vec2 vTextureCoord; void main(void) { …… vTextureCoord = aTextureCoord; } </script>
片元着色器代码
<script id="shader-fs" type="x-shader/x-fragment"> precision mediump float; varying vec2 vTextureCoord; uniform sampler2D uSampler; void main(void) { gl_FragColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t)); } </script>
在顶点着色器中定义attribute变量aTextureCoord和相应的varying变量,表示对应的顶点的纹理坐标。纹理坐标是两个分量都在区间[0,1]之间的二维向量,是从纹理上“取色”必不可少的参数。在某种意义上,纹理就是从纹理坐标到颜色的映射。在顶点着色器看来,除了分量的个数,aTextureCoord和aVertexColor没有区别,都是直接赋给对应的varying变量。
片元着色器接受经过光栅化的vTextureCoord,并根据其所载有的纹理坐标从纹理uSampler中读取颜色,作为对应像素在屏幕上显示的颜色。
6..实现环境光和漫反射下的平行光
环境光,即空气分子和分子团的散射光,均一地影响每个像素;漫反射下的平行光均一地影响着同一个平面的所有像素,但是收到该平面与平行光的夹角的影响。
顶点着色器代码
<script id="shader-vs" type="x-shader/x-vertex"> …… attribute vec3 aVertexPosition; attribute vec3 aVertexNormal; uniform vec3 uAmbientColor; uniform vec3 uLightingDirection; uniform vec3 uDirectionalColor; void main(void) { …… float directionalLightWeighting = max(dot(transformedNormal, uLightingDirection), 0.0); vLightWeighting = uAmbientColor + uDirectionalColor * directionalLightWeighting; } </script>
片元着色器代码
<script id="shader-fs" type="x-shader/x-fragment"> precision mediump float; varying vec2 vTextureCoord; varying vec3 vLightWeighting; uniform sampler2D uSampler; void main(void) { vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t)); gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a); } </script>
顶点着色器中定义了每个点的法线方向aVertexNormal(实际上是点所在平面的发现方向,但是必须对应到点上,因为顶点着色器以顶点为处理单元),还有若干uniform变量:环境光强度、平行光强度和平行光方向。根据设定的物理模型,对每个顶点返回vLightWeighting值,这个varying变量表示在上述各个变量的作用下,这个顶点颜色的各分量收到的影响。上述各个变量是如何“作用”的,不是这里讨论的重点,如果你急切了解,可以独立思考或者仔细阅读代码(如果了解着色器中三种不同地位的变量,相信这不是难事)。
片元着色器逐像素地对从纹理中提取出的颜色再乘以光栅化过的vLightWeighting。
缓冲区
缓冲区是驻存于内存中的javascript对象,存储着即将推送到着色器中的attribute对象。
最常用的attribute对象莫过于记录了空间中点位置信息的aVertexPosition了。缓冲区如同一个长长的队列,着色器每处理完一个顶点(或和顶点对应的其他attribute对象),缓冲区就提供下一个顶点给着色器处理。
建立一个缓冲区:
// 创建缓冲区 vertexPositionBuffer = gl.createBuffer(); // 绑定缓冲区为“当前缓冲区” gl.bindBuffer(gl.ARRAY_BUFFER, vertexPositionBuffer); // 为缓冲区填充数据 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
值得注意的是,WebGL中只有将一个缓冲区绑定为“当前缓冲区”时,才可以对其进行操作。为缓冲区填充数据时,需要传入一个Float32Array对象,该对象是基于数组vertices建立的,该数组存储着所有顶点文本形式的坐标。Javascript中,数组是一个文本对象,而Float32Array对象是一个二进制对象,显然二进制对象工作效率更高。
vertices = [ -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, …… -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, ];
将缓冲区中的数据推送到着色器中还需要涉及到“着色器程序”,一个负责联系着色器和缓冲区的的Javascript对象。真是麻烦,画一个点就需要这么多准备,不过还好着色器程序不是我们的重点,而且代码虽长但却很好理解。
着色器程序大致做了这样的事情:从html文档中读取用x-shader语言编写的着色器脚本,并且根据脚本生成程序,测试程序是否能够正常运行,然后将程序中所有uniform变量和attribute变量的地址存储到更加友好的其他Javascript对象中(通常是着色器程序自己的属性),比如把aVertexPosition变量的地址存储到shaderprogram.vertexPositionAttribute中:
shaderProgram.vertexPositionAttribute = gl.getAttribLocation(shaderProgram, "aVertexPosition");
gl.enableVertexAttribArray(shaderProgram.vertexPositionAttribute);
这样就可以将缓冲区的数据推送到着色器中了。
gl.bindBuffer(gl.ARRAY_BUFFER, vertexPositionBuffer); gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);
其中参数3表示缓冲区的每一个元素(这里就是每一个顶点位置了)由3个分量组成——LearningWebGL教程的例子里,将普通坐标转化为齐次坐标的工作在顶点着色器中进行。
总之,缓冲区将结构化的三维模型数据(往往还是文本形式的)处理成着色器能够理解变量类型,着色器运行在针对浮点运算做特殊优化的显卡上,在片元着色器逐像素地生成颜色时,我们就要开始绘制“帧”了。