欢迎来到WebGL系列教程的第7课,这节课基于NeHe OpenGL教程的[第7课](http://nehe.gamedev.net/data/lessons/lesson.asp?lesson=07)部分编写,由于WebGL光照处理有些麻烦,所以没有放在第6课讲解,本课我们会学习在WebGL页面中加入简单光照;这比在OpenGL中复杂一些,但是希望能讲明白。
如下是这节课的demo,在支持WebGL的浏览器中运行时的样子:
【看云目前不支持youtube视频,又不想放国内的广告视频,只能先放个链接了】
https://youtu.be/wq68Q6WJgyo
[点击这里](http://learningwebgl.com/lessons/lesson07/index.html),如果你的浏览器支持WebGL,你可以看到一个缓慢旋转的立方体, 并且看上去在被一个点光源照射,这个点光源的位置在前方【即处于你和立方体之间】,稍微偏右上方。实际上在页面中可看出,光照方向为$$ \vec a $$= (-0.25, -0.25, -1.0), 即从(0, 0, 0)点指向(-0.25, -0.25, -1.0)点的方向。
你可以使用画布下面的复选框来切换光照开关,以观察效果的不同。也可以改变平行光和环境光的颜色(稍后会有更精确的介绍),以及平行光的方向。多把玩下这个样例;当平行光的RGB值大于1时会出现很有趣的效果(但如果大于5时,则会丢失很多纹理细节)。同时,就像上节课那样,你可以使用方向键来加速或减慢立方体旋转的速度,使用PageUp和PageDown来缩放立方体。这次我们只使用最好的纹理过滤器【mipmap】,所以F键已经没用了。
下面讲解这个Demo的工作原理...
本课的源码可以查看:https://github.com/gracefung/webgl-codes/tree/master/lesson07 【这是繁体译文对应的github源码,感觉还不错】
在我们了解WebGL的光照工作原理之前,我要宣布一个坏消息。WebGL本身并不支持光照。而OpenGL可以让你至少指定8个光源,并且能够替你处理它们。WebGL则是把所有的事情都交给你自己做。**但是** -- 重点来了 -- 一旦被解释清楚,光照其实非常简单。如果这几节课你对shader感觉还不错,那么理解光照肯定没问题 -- 并且,作为新手, 编写一个简单的光照代码,有助于你日后更容易理解那些进阶的代码。毕竟,OpenGL的光照系统只是模拟了真实场景的最基本方面 -- 它并不能处理阴影, 例如, 对于曲面的模拟,它的表现效果就很粗糙 -- 所以除了一些简单的场景,其它的复杂光照还是需要自己动手写代码。
好啦,让我们先想想从光照中想得到什么。目标是能够在场景中模拟一些光源。这些光源不需要是可见的,但他们需要逼真的照亮3D物体, 即物体面朝光源的面是亮的,远离光源的面是暗的。换句话说, 我们想能够指定一组光源,对于3D场景中的每一部分,我们想看看所有光线是如何作用它的。现在我相信你已经足够了解WebGL,并感觉到这些是放在shader中处理。讲的更确切些,这节课要做的就是编写**vertex shader**处理光照。对于每一个顶点, 我们会计算出光线是如何作用它的,然后调整它的颜色。目前我们只处理一条光线的情况;多光线的情况只是为每条光线重复相同的处理过程,然后将结果相加。
还有一点要说明;因为我们是基于逐顶点来计算光照,所以介于2顶点之间的像素光照效果,是通过常见的线性插值计算得出。也就是说顶点之间的空隙会假设成都是平面而被照亮;恰巧,我们绘制的是一个立方体,这正是我们想要的例子!对于曲面,就需要每一个像素独立的计算光照效果,技术上被称做**“逐片段(或逐像素)光照”**,这种效果会非常好。我们将在后续课程中看到逐片段光照,而我们目前做的,可以称之为“逐顶点光照”。
好,进行下一步:如果我们的任务是编写一个vertex shader,来计算出一个光源是如何作用顶点的颜色,该怎么做?好吧,一个好的突破口是**Phong反射模型**。通过以下要点,这个模型是最容易理解的:
* 虽然在现实世界中光照只有一种类型,但是为了方便计算,图形学中对光照做了2种分类:
>[success]1. 来自于特定方向的光线且只能照亮面朝它的物体。我们称之为**平行光**。
>2. 来自于任何地方的光线且均匀的照亮所有的物体,无论它朝向何方。这种称之为**环境光**(当然,在真实世界中,这种光线只是平行光照射到其它物体上反射而散发出来的,比如空气,尘埃等。但是为了我们的模拟,需要对它单独建模)。
* 当光线抵达物体表面, 会发生2种情况:
>[success]1. 漫反射:即,无论入射角度是多少, 都会朝所有方向均匀的反射。无论你从哪一个角度去观察,反射光的亮度都完全依赖于入射光的入射角度 -- 入射角越大,反射光越暗。当我们思考一个物体被照亮时,一般考虑的就是漫反射。
>2. 镜面反射:即,就像镜子一样。一部分光线通过这种方式,按照入射角度被反射出来。在这种情况下,反射光的亮度取决于你的眼睛和反射光是否在同一直线上 -- 即, 它不仅依赖于入射角度,还依赖于你的视线和物体表面的夹角【视角】。这种镜面反射导致了物体的“闪烁”或“高光”,并且镜面反射的能量,根据材质不同变化明显;粗糙的木料只有极少量的镜面反射,而高度抛光的金属则有大量的镜面反射。
Phong模型通过声明所有光线都有2个属性,对上面这个四步系统进一步简化:
>[info]1. 它们产生的漫反射光的RGB值
>2. 它们产生的镜面反射光的RGB值
同时所有的材质都有4种属性:
>[info]1. 它们反射的环境光RGB值
>2. 它们反射的漫反射光RGB值
>3. 它们反射的镜面反射光RGB值
>4. 物体的反光度, 这个决定了镜面反射的细节。
对于场景中的每一个点,它的颜色都是由照射光的颜色,材质本身的颜色,以及光照效果混合而成。所以,为了根据Phong模型来完整指定一个场景中的光照,我们需要每个光线的2种属性,和物体表面的每个点的4种属性。环境光由于它的特点,不依赖于任何特定光线,但是我们依然需要找到一种方式,来存储整个场景的所有环境光强度;有时为了简单起见,我们只需为每一个光源指定一个环境等级,然后把它们全加起来放在一个变量中。
无论怎样,一旦我们拥有了所有信息, 我们可以根据环境光,平行光,镜面反射光计算出每个点的颜色,然后把它们相加来算出全部颜色值。计算原理如下图所示:
![](https://box.kancloud.cn/82bc557693384e76168f8f7ae5da0683_800x223.png)
>[danger] **shader所要做的工作就是计算出每个顶点上的环境光,漫反射光,以及镜面反射光的红色分量,绿色分量,和蓝色分量,构成颜色RGB值,再相加在一起,最终输出结果。**
现在,为了课程说明,我们将做些简化。只考虑漫反射光和环境光,而忽略镜面反射。我们依然使用上节课的纹理立方体,并假设纹理的颜色就是漫反射光和环境反射光的颜色值。最后,我们只考虑一种简单的漫反射光 -- 平行光。可以用下面的图来讲解。
![](https://box.kancloud.cn/cdf58e2630fb44631abfe2068ae4bd72_300x162.png)
从单方向照射一个表面的光分2种 -- 平行光,即按照相同的方向穿过整个场景;点光源,来源于场景内一个点发出的光线,即不同的地方,光线角度也不同。
对于平行光,光打到给定面上的任何一点角度总是相同的 -- 例如上图中的AB点。想象一下太阳光;所有的射线都是平行的。
再来看从点光源发出的光,每一个顶点上的入射角度都不一样,例如下图中的A点,入射角度大概是$$ 45^o $$,而B点的入射角已基本是$$90^o$$。
![](https://box.kancloud.cn/39bb283357c8d51dd7e3ae220f1d7f25_300x309.png)
这就意味着对于点光源,我们需要为每一个顶点计算出光的入射方向,而对于平行光,我们只需要知道平行光源的方向。这就使得点光源的处理稍微有些困难,所以这节课只讨论平行光,后续课程再讲解点光源,不过你自己想要研究出来,应该也不会太难。
所以,目前我们已经把问题精炼了不少。我们知道场景中的所有光都会来自于一个特定方向,并且这个方向不会随顶点而变化。这意味着我们可以把它放到一个uniform变量中,供shader使用。我们也知道光线对于每个顶点的作用效果,取决于在该点的入射角度,所以我们需要用一种方式来描述表面的朝向。在3D几何中最好的办法就是指定表面在该点的**法线向量**;这样我们就可以通过一组3个数字来描述表面的朝向。(在2D几何中,我们可以等价使用切线 -- 即,表面在该点的方向 -- 但是在3D几何中切线可以有2个方向,所以我们就需要2个向量来描述它,但是法线只用一个向量就够了)。
一旦我们有了法线,就剩下最后一样东西我们就可以写shader了;给定一个表面在该点的法线向量,和入射光的入射光方向向量,我们需要知道有多少光会被表面漫反射。可以证明这个值与两向量夹角的余弦成正比。如果法线为$$0^o$$(即,来自于所有方向的入射光,全都以$$90^o$$打到表面上),那么我们可以说它反射了所有的光。如果入射光和法线夹角90度,就没有任何光被反射。而在这2种情况之间的,都遵循余弦曲线。(如果夹角超过90度,理论上会计算出一个负值,这显然是不合理的,所以我们实际使用的要么是余弦值,要么是0,哪个大就用哪个)
所幸的是,计算2个向量夹角的余弦值很简单,如果它们还都是单位向量;那么对它们求点积就是夹角余弦值。更加方便的是,点乘运算已内置在shader中,调用名为*dot* 的函数即可。
哇哦!开端讲了这么多理论 -- 但我们已经知道处理简单平行光所需要做的工作:
>[warning]* 存放一组法线向量,每个顶点一个。
>* 描述光线的一个方向向量。
>* 在vertex shader中,计算顶点法线和入射光向量的点积,适当的加权计算出颜色值,同时不要忘了环境光的影响。
让我们看看代码是如何工作的。将会从底层开始向上讲解。很明显这节课的html页面和上节课有区别,因为我们有了额外的输入框,但这会先不讲这些细节... 让我们先看JavaScript代码,首先看*initBuffers* 函数。你会发现,在创建顶点位置数组之后,纹理坐标数组之前,我们创建了法线数组, 现在看这些代码应该会很熟悉:
~~~
cubeVertexNormalBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
var vertexNormals = [
// Front face
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
// Back face
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
// Top face
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
// Bottom face
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
// Right face
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
// Left face
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexNormals), gl.STATIC_DRAW);
cubeVertexNormalBuffer.itemSize = 3;
cubeVertexNormalBuffer.numItems = 24;
~~~
下面我们看*drawScene* 函数,如下代码用来将法线数组绑定到对应的shader属性中:
~~~
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, cubeVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);
~~~
然后, 由于我们本课只是用一种纹理过滤器,所以移除了上节课中纹理可配置的代码:
~~~
gl.bindTexture(gl.TEXTURE_2D, crateTexture);
~~~
接下来的部分稍微有些复杂。首先,我们需要查看“lighting”复选框是否被勾选,所以我们在shader中设置了一个uniform变量来指示这个值:
~~~
var lighting = document.getElementById("lighting").checked;
gl.uniform1i(shaderProgram.useLightingUniform, lighting);
~~~
然后,如果启用了光照,我们读取输入框中环境光的红,绿,蓝分量值,并将它们推送给shader:
~~~
if (lighting) {
gl.uniform3f(
shaderProgram.ambientColorUniform,
parseFloat(document.getElementById("ambientR").value),
parseFloat(document.getElementById("ambientG").value),
parseFloat(document.getElementById("ambientB").value)
);
~~~
接着, 我们还需要推送平行光的方向:
~~~
var lightingDirection = [
parseFloat(document.getElementById("lightDirectionX").value),
parseFloat(document.getElementById("lightDirectionY").value),
parseFloat(document.getElementById("lightDirectionZ").value)
];
var adjustedLD = vec3.create();
vec3.normalize(lightingDirection, adjustedLD);
vec3.scale(adjustedLD, -1);
gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD);
~~~
从上面的代码,你可以发现我们在传给shader之前,调整了平行光的方向向量,使用了glMatrix的vec3模块 -- 这个模块类似于我们之前描述模型视图,和投影矩阵时用到的mat4模块,都是glMatrix的一部分。第1步变换,`vec3.normalize` 是为了放大或缩小向量至长度为1;你可能还记得夹角余弦值等于2向量点积的前提是,这2个向量的长度都是1。上面我们定义的法线向量都是1,但是平行光的方向是由用户填写的(让他们自己填出一个单位向量,可能是一件很头疼的事情),所以就由我们来变换。第2步变换是让向量乘以一个标量-1 -- 即,反转它的方向。这是因为填写平行光方向时,我们一般都是想表达光传输的方向【即光照向哪里】,而我们之前讨论的计算方法中,用到的都是光来自哪里【**这个结合法线点积很好理解:法线的方向是朝外的,即背离物体表面,如果计算点积时,用的是入射方向,那么2个向量的夹角其实是个钝角,算出来的余弦值是负的,所以当我们计算2向量点积时,由于法线朝外,所以入射光方向,取用的也是朝外方向,即入射方向的反方向**】。当我们做完以上转换后,我们使用`gl.uniform3fv`将其传递给shader,这个函数将一个三元素的Float32Array数组放入uniform变量中。
下面的代码就简单多了;就是将平行光的颜色部分传递给shader中对应的uniform变量:
~~~
gl.uniform3f(
shaderProgram.directionalColorUniform,
parseFloat(document.getElementById("directionalR").value),
parseFloat(document.getElementById("directionalG").value),
parseFloat(document.getElementById("directionalB").value)
);
~~~
以上就是drawScene函数的所有改动。接下来看键盘交互的代码,它只是移除了F键的控制,我们可以忽略掉这个简单改动,下一个有趣的变化是*setMatrixUniforms* 函数, 你应该还记得将模型视图矩阵,和投影矩阵传递给shader的uniforms。我们增加了4行代码,拷贝了一个新的,基于模型视图的矩阵:
~~~
var normalMatrix = mat3.create();
mat4.toInverseMat3(mvMatrix, normalMatrix);//这里涉及到 **法线变换** 知识,建议google
mat3.transpose(normalMatrix);
gl.uniformMatrix3fv(shaderProgram.nMatrixUniform, false, normalMatrix);
~~~
正如你所想,normalMatrix用来变换法线,我们不能像变换顶点位置那样,使用同样的模型视图矩阵来变换法线向量,因为法线会因为平移,旋转操作产生变化 -- 举个例子,如果我们忽略旋转,并假设做了$$\vec a = (0, 0, -5)$$的平移操作,那么法线$$\vec n = (0, 0, 1)$$就会变为$$\vec n = (0, 0, -4)$$,结果就是不仅向量长度变的太长,而且指向了一个错误的方向。我们可以绕过这个错误;你也许已经注意到在vertex shader中,当我们将一个3元的顶点位置与$$4 \times 4$$的模型视图矩阵相乘时,为了使它们相容,我们通过在尾部添加1,将顶点位置向量扩展为4个元素。这个1不仅是为了填充长度,也是为了使平移,旋转,以及其他的变换操作得以生效,所以如果我们恰巧用0来替代1,我们就可以做乘法运算时,忽略掉平移操作【这里涉及到**模型视图矩阵的分解**,[这里有篇文章](http://blog.csdn.net/dcrmg/article/details/53088617)讲解的很明白】。这个方法目前来看满足我们的要求,但如果我们的模型视图矩阵包含了不同的变换,尤其是缩放和裁剪时,这个方法就无效了。例如,假设我们的模型视图矩阵是将物体放大2倍,那么即使末尾添加了0, 法线长度依然会变为原来的2倍 -- 这就会导致光照计算各种问题。所以,为了避免养成坏习惯,我们还是要用正确的方法解决问题。
使法线指向正确方向的方法是:使用模型视图矩阵左上角的$$3\times3$$部分的逆的转置。【详见[法线变换](http://blog.csdn.net/bugrunner/article/details/7285356)】
好吧,当我们计算完这个矩阵,就可以将它像其它矩阵那样传递给shader的uniform中。
继续看代码,会发现有一些不重要的改动,是关于纹理加载的,现在只加载一张mipmap贴图,而不再是上节课那样加载3个贴图,还有*initShaders* 方法中新添一些代码,用来初始化*vertexNormalAttribute*,以便*drawScene* 方法可以用它把发现传递给shader,其它新添的uniform也都是类似的方式处理。这些都没什么细节可讲,直接看shader。
首先是fragment shader,很简洁:
~~~
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);//光作用在材质上就是相乘运算。
}
~~~
你会发现,我们先是从纹理中提取颜色,但是在返回前,我们通过一个varying变量*vLightWeighting* 调整了它的RGB值,vLightWeighting是一个3元向量,就像你猜想的那样,它存放了R, G, B的调整因子,这些调整因子是在vertex shader中计算光照得出的。
让我们来看看vertex shader是如何计算的:
~~~
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
attribute vec2 aTextureCoord;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform mat3 uNMatrix;
uniform vec3 uAmbientColor;
uniform vec3 uLightingDirection;
uniform vec3 uDirectionalColor;
uniform bool uUseLighting;
varying vec2 vTextureCoord;
varying vec3 vLightWeighting;
void main(void) {
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
vTextureCoord = aTextureCoord;
if (!uUseLighting) {
vLightWeighting = vec3(1.0, 1.0, 1.0);
} else {
vec3 transformedNormal = uNMatrix * aVertexNormal;
float directionalLightWeighting = max(dot(transformedNormal, uLightingDirection), 0.0);
vLightWeighting = uAmbientColor + uDirectionalColor * directionalLightWeighting;//多光源的作用效果,用加法,就像这里的环境光效果 + 平行光效果,RGB这里可以理解为强度,颜色强度之类
}
}
~~~
新属性*aVertexNormal* 当然存放的就是我们在*initBuffers()* 中指定的顶点法线。*uNMatrix* 就是法线变换矩阵,*uUseLighting* 是一个uniform变量,指示是否启用光照,*uAmbientColor, uDirectionalColor, uLightingDirection* 表示的是用户在页面输入框中的可输入参数。
按照我们上面讲述的那一大堆数学知识, 实际实现的代码应该很容易理解。vertex shader的主要输出就是varying变量*vLightWeighting*,即我们刚才在fragment shader中看到的用来调整纹理颜色的因子,如果禁用了光照,就是用默认值$$\vec w = (1, 1, 1)$$,即不调整颜色。如果启用光照,我们首先应用法线变换矩阵*uNMatrix* 计算出法线朝向,然后求法线方向和光线方向的点积,以得出有多少光会被反射(最小是0,就像我之前提到的)。这个值乘以平行光的颜色,然后再加上环境光颜色,计算出的结果就是最终的光照权重,即fragment shader中用到的*vLightWeighting*。
以上就是本课所有内容:对于图形学中的光照原理,你已经有了扎实的基础,并且知道了如何实现2种简单光照:平行光和环境光。这一切都是自己编写的。