(转) 环境光、平行光、点光源、phong shader (webgl 着色渲染)
平行光与环境光
转自: http://www.hiwebgl.com/?p=282
在我们详细讲解如何在WebGL中添加和设置光照之前,我需要先宣布一个坏消息!那就是,WebGL完全没有提供任何对光照系统的内置支持!不像OpenGL,它的实现版本最少也允许你指定8道光源,并且可以替你处理这些光源,而WebGL却完全撒手不管,都推给了你自己解决。但是——这是一个转折度很深的“但是”——如果解释清楚了,其实光照是一个相当简单的概念。如果你能很好的理解我们之前讲过的着色器的知识,那学习光照应该也没什么问题——只有你像新手一样从编写简单的光照代码开始,才会让你更容易理解当你成为高手后必须去面对的那些代码。毕竟,在OpenGL中光照是描绘真实场景时最基本的元素,然而它却并不能处理阴影,比如说,对于弯曲表面它产生的效果非常粗糙。所以稍微复杂一些的场景中的光照都需要手工编写代码。
好了,让我们先来想一下我们想从光照中得到什么效果。我们的目的是在场景内模拟出几道光源。这些光源本身不需要是可见的,但它们发出的光必须能够作用于3D物体的表面,让物体面对光源的那一侧显得明亮,同时背对光源的部分变得阴暗。换句话说,我们想要指定一系列的光源,然后让这些光源可以作用于我们的3D场景内的任何一个部分。现在,我想你应该已经知道WebGL是通过向着色器中填充一些东西来实现这些效果的。更进一步的说,我们这节课中要做的就是向顶点着色器写入代码来处理光照。我们将要计算出光照对每个顶点的影响,并以此来调整顶点的颜色。虽然现在我们只准备计算一道光源,但计算多道光源也不复杂,只是重复这一过程然后把结果累加在一起。
还需要说明的是,我们只是基于每个顶点来处理光照效果,所以顶点间像素的光照效果都是由线性插值来实现的。也就是说,物体顶点间的部分都会被当成平面来处理,而碰巧的是,我们画的是一个立方体,这正是我们想要的!对于弯曲的表面,如果你想为每个像素都独立计算出光照效果,你可以使用一种称之为“逐像素光照(逐片元光照)”(per-pixel lighting或per-fragment lighting)的技术,它能实现更好的效果。我们将在以后的课程中学习这项技术。而现在我们做的逻辑上被称为“逐顶点光照”(per-vertex lighting)。
好了,下一步,如果我们要向顶点着色器写入代码来处理一道光源对顶点颜色的影响,那我们应该怎么做呢?让我们先从冯氏反射模型(Phong Reflection Model)开始。首先你要理解下面几点:
- 虽然在真实世界里不存在光照类型的概念,但在图形学中我们却将光的类型按照光与物体表面的作用来进行了区分:
- 一种是从特定方向射入并只会照亮面对入射方向的物体,我们称之为平行光(directional light)。
- 另一种光是来自所有方向并且会照亮所有物体,不管这些物体的朝向如何,我们称之为环境光(ambient light)。当然在真实世界里,这只是平行光照到其他物体上,比如空气、灰尘等等,然后反射出来的散射而已。但是在这里,我们需要把它单独作为一个光照模型列出来。
- 当光照到物体表面,会发生两种情况:
- 漫反射(Diffuse):无论光的入射角度如何,都会向所有方向发生反射。反射光的亮度只和光线的入射角度有关,与观察角度无关。光线越平行于物体表面,则反射光越弱,表面越暗;光线越垂直于表面,反射光越强,表面越亮。漫反射是我们通常想到一个物体受到光照时需要首先想到的。
- 镜面反射(Specular):这就像镜子一样,反射光将按照和入射角相同的角度反射出来。这种情况下,你看到的物体反射出来的光的亮度,取决于你的眼睛和光反射的方向是否在同一直线上;也就是说,反射光的亮度不仅与光线的入射角有关,还与你的视线和物体表面之间的角度有关。镜面反射通常会造成物体表面上的“闪烁”和“高光”现象,镜面反射的强度也与物体的材质有关,无光泽的木材很少会有镜面反射发生,而高光泽的金属则会有大量镜面反射。
冯氏反射模型引申了这个四步走的光照系统,首先所有的光线都有以下两个属性:
- 发生漫反射光的RBG值。
- 发生镜面反射光的RGB值。
其次所有材质都有以下四个属性
- 反射的环境光RGB值
- 反射的漫反射光RGB值
- 反射的镜面反射光RGB值
- 物体的反光度,它决定了镜面反射的细节
对于场景中的每一点,它的颜色都是由照射光的颜色、材质本身的颜色和光照效果混合起来的。所以,根据冯氏反射模型,为了解决场景中的光照,每条光线我们都需要知道两个属性,每个物体表面上的点都需要4个属性。环境光应当是自然的,而不是特定的光线,但我们依然需要找到一种方法来储存整个场景中的环境光;有时可以用最简单的方法,就是为每个光源设置一个环境等级,然后把它们都放到一个单一项中。
好了,我们有了以上的预备知识,我们就能计算出环境光、平行光和镜面反射光照在任何一个点上的颜色,然后再把它们组合到一起,就得到了最后的颜色值。下面这幅图清晰的解释了我们的工作原理。而我们所有的着色器需要做的就是分别计算出在环境光、漫反射光和镜面反射光下每个顶点的红、绿、蓝的颜色,然后组成RGB值,再组合到一起,最后输入结果。
在这节课中,我们先搞的简单一些。我们只会考虑漫反射和环境光,而忽略镜面反射。我们将会继续使用上一节课中绘制的贴图的立方体,并且用纹理的颜色来计算漫反射和环境光。最后,我们只会考虑一种最简单的漫反射光,那就是平行光。下面我用图表来解释一下。
从一个方向上来的光可以分为两种。一种是简单的平行光,来自于同一个方向的平行光束穿越整个场景。另一种是点光源,来源于场景内的一个点发出的光线,也就是说每个地方的光线角度都不一样。
对于简单的平行光来说,当光线打到物体表面的顶点上(图中的A点和B点),入射角永远都是相同的。想一下太阳光,光线都是平行的。
相反,对于点光源,A点和B点的入射角是不同的。A点差不多是45°,而B点则接近0°,也就是说B点的入射光线垂直于物体表面。
这也就意味着对于点光源,我们需要为每个顶点都计算出各自不同的光线入射角度;然而对于平行光,我们只需要使用一个固定的角度。这就使得点光源变得有一些复杂,所以这节课中我们只会处理平行光。在以后的课程中我们再来学习点光源,不过即使你自己研究一下应该也是很容易搞清楚的。
这样我们就把问题精炼了。我们知道我们场景中的所有光线都会来自于一个固定的方向,而且这个方向对于每个顶点来说都是不会改变的。也就是说我们可以把它放到uniform变量中,然后提供给着色器来调用。我们同样知道每个顶点上的光照效果取决于光线的入射角度,所以我们需要找到一个可以代表物体表面朝向的东西。对于3D几何体,最好的办法就是指定顶点所在表面的法线向量,这个向量允许我们用3组数字表示出物体表面的朝向。(在二维世界中我们可以同样使用切线来达到这一目的,但是在三维世界中,切线的垂线是指向两个方向的,所以我们要用两个向量来表示它,而表示法线我们使用用一个向量就可以了。)
除了法线之外,在像着色器写入代码之前我们还需要最后一样东西。我们指定了顶点平面的法线向量,还有用来表示光照方向的向量,我们还需要计算出物体表面漫反射了多少光。这与这两个向量之间角度的余弦值成正比。当法线向量与光照方向向量的夹角是0度的时候(也就是说,光线完全照射到物体表面,光线方向90°垂直于物体表面),我们可以看做物体反射了所有的光;当夹角为90度的时候,没有任何光线被反射;当夹角处于0到90度之间时,应当符合余弦曲线。(如果当角度大于90度时,根据我们的理论会得出一个负值的反射光,这显然是很扯淡的,所以对此我们设其为余弦值或0,两者无论哪个都比负值要大。)
幸运的是,计算这两个向量夹角的余弦值并不是什么复杂的计算,如果它们两者的长度都是1,那我们只要使用这两个向量的点积即可。更幸运的是,点积运算是内置于着色器的,我们只要使用这个叫做dot的函数即可。
哇!还没开始我们就讲了这么一大堆理论,现在列一下我们要做的事情的清单:
- 为每一个顶点指定一个法线向量
- 指定光线的方向向量
- 在顶点着色器中计算法线向量和光线方向向量的点积,然后计算出相应的颜色值,同时加入环境光的分量。
现在让我们来看看代码吧。我们将从下至上的来讲解。首先,显然最下面的HTML部分的代码发生了变化,因为我们增加了一些新的输入框。但是我不想在这部分在多费口舌。让我们往上瞧瞧,来看看Javascript部分。先来看看initBuffers函数,在建立顶点位置数组和纹理坐标数组的代码中间,你会看到我们建立法线向量数组的代码。现在你应该对这种形式的代码非常熟悉了吧。
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 |
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函数中,我们把法线向量数组绑定到相应的着色器的属性中。
424 425 |
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer); gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, cubeVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0); |
在这之后,还在drawScene函数中,我们移除了上一课中切换纹理过滤方式的代码,这里我们只使用一种纹理过滤方式。
431 |
gl.bindTexture(gl.TEXTURE_2D, crateTexture); |
接下来有点麻烦。首先我们需要先检测一下“lighting”复选框是否被选中,并通过设置一个uniform变量来告诉着色器。
433 434 |
var lighting = document.getElementById("lighting").checked; gl.uniform1i(shaderProgram.useLightingUniform, lighting); |
然后,如果lighting复选框被选中,我们读出用户在输入框中键入的环境光的红、绿、蓝的颜色值,并传递给着色器。
435 436 437 438 439 440 441 |
if (lighting) { gl.uniform3f( shaderProgram.ambientColorUniform, parseFloat(document.getElementById("ambientR").value), parseFloat(document.getElementById("ambientG").value), parseFloat(document.getElementById("ambientB").value) ); |
然后我们要传递光线方向给着色器:
443 444 445 446 447 448 449 450 451 |
var lightingDirection = new okVec3( parseFloat(document.getElementById("lightDirectionX").value), parseFloat(document.getElementById("lightDirectionY").value), parseFloat(document.getElementById("lightDirectionZ").value) ); var adjustedLD = lightingDirection.normalize(false); adjustedLD = okVec3MulVal(adjustedLD, -1.0); gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD.toArray()); |
你会发现在传递给着色器之前,我们对光线方向向量做出了一些调整。我们使用了okVec3,和okMat4一样,这都是Oak3D对于数学概念的封装。然后,我们先执行了lightingDirection.normalize,将其长度调整为1。你应该还记得两个长度为1的向量之间的夹角的余弦值等于它们的点积,所以法线向量和光线方向向量的长度都应该为1。我们之前定义的法线向量已经将长度设置为1了,但是光线方向是由用户来自定义的,而且对于用户来说让他们自己去将光线方向向量调整为1然后再输入恐怕是不太现实的,所以我们这里需要做一个转换。然后我们将光线方向向量乘以一个标量-1,用于调转向量的方向。这是因为我们要求用户指定的是光线射出的方向,而我们之前讨论的算法中需要的是光线射入的方向。完成后,我们用gl.uniform3fv函数将它传递给着色器,它将一个vec3函数处理过的含有3个元素的Float32Array放入到一个uniform变量中。
接下来的代码就要简单多了,只是将平行光的颜色分量传送到相应的着色器uniform变量中。
453 454 455 456 457 458 459 |
gl.uniform3f( shaderProgram.directionalColorUniform, parseFloat(document.getElementById("directionalR").value), parseFloat(document.getElementById("directionalG").value), parseFloat(document.getElementById("directionalB").value) ); } |
这就是全部drawScene函数的变化的代码。移动到处理键盘输入的代码部分,在这里我们移除了使用F键切换纹理过滤方式的代码。接下来比较有趣的变动位于setMatrixUniforms函数中,提到这个函数你应该会想起将模型视图矩阵和投影矩阵拷贝并传递给着色器的uniform变量。在这儿我们增加了2行代码用于传递一个新的基于模型视图的矩阵:
198 199 |
var normalMatrix = mvMatrix.inverse().transpose(); gl.uniformMatrix4fv(shaderProgram.nMatrixUniform, false, normalMatrix.toArray()); |
和你猜的一样,这个名叫normalMatrix的矩阵就是用来转换法线向量的。我们用规则的模型视图矩阵来转换顶点位置,但是我们不能用同样的方式来转换法线向量。这是因为法线向量会随着我们的平移和旋转发生变化。比如说,如果我们忽略旋转并且假设做了一个(0,0,-5)的平移,那么法线向量(0,0,1)就会变成(0,0,-4),这不仅长度不对而且根本就指向了错误的方向!我们或许可以解决这个问题。你应该注意到了在顶点着色器中,当我们要把一个含有3个元素的顶点位置数组乘以一个4×4模型视图矩阵的时候,为了使两者相匹配,我们扩充了顶点位置数组,在它的末尾加了一个1。这个1不仅仅是用来填充数组,还可以使平移、投影等空间变换应用于矩阵变换之上。所以这里也许我们可以为法线向量的末尾加上一个0而不是1,这样就可以忽略掉那些变换。目前来看,这样我们可以很完美的解决问题。但是不幸的是,当模型视图矩阵包含不同的空间变换时,尤其是缩放和扭曲时,它将不再有用。比如说,如果模型视图矩阵将我们要绘制的物体放到两倍大小,那法线向量也会被拉伸,即使我们在末尾加了0。这会导致严重的光照错误。所以,为了避免养成坏习惯,我们还是不能作弊,要走正道啊!
让法线向量永远指向正确方向的正规解决方法是,使用模型视图矩阵的左上角3×3矩阵的逆转置矩阵,这样可以去掉矩阵中非正交的因素。形象的说, 就是只保留旋转,而不能对向量做缩放和移动,否则会这会改变向量的方向,这并不是变换矩阵想要做的。
不管怎么样,当我们计算完毕这个矩阵,就可以像其他矩阵一样传递给着色器了。
向上移动一下光标,在载入纹理的代码部分也发生了一些细微的变化,我们只按照mipmap的方式载入了一次纹理,而不是上节课中的3次。另外在initShaders函数中,我们初始化了vertexNormalAttribute属性以供drawScene函数调用。然后类似的,我们处理了所有新引入的uniform变量。这些都不值得细说,让我们赶紧直接跳到着色器部分吧!
10 11 12 13 14 15 16 17 18 19 20 21 22 |
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); } |
你会发现,我们和第六课中一样从纹理中提取了颜色信息,但是在返回的时候我们使用一个叫做vLightWeighting的Varying变量调整了它的R、G、B值。vLightWeighting是一个含有3个元素的向量,用来储存经过顶点着色器计算过的光照的红、绿、蓝的颜色值。
那顶点着色器是如何计算的呢?让我们看一下顶点着色器的代码。
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
attribute vec3 aVertexPosition; attribute vec3 aVertexNormal; attribute vec2 aTextureCoord; uniform mat4 uMVMatrix; uniform mat4 uPMatrix; uniform mat4 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 * vec4(aVertexNormal, 1.0)).xyz; float directionalLightWeighting = max(dot(transformedNormal, uLightingDirection), 0.0); vLightWeighting = uAmbientColor + uDirectionalColor * directionalLightWeighting; } } |
新的属性aVertexNormal当然是用来储存我们在initBuffers函数中指定并且在drawScene函数中传递给着色器的顶点法线。uNMatrix就是我们的法线矩阵。uUseLighting是用来指定是否开启光照的uniform变量。uAmbientColor、uDirectionalColor和uLightingDirection显然都是用于储存用户在网页上输入的各种值的。
在数学之光的照耀下,我们遍历了整个代码,实际上应当是很容易理解的。着色器主要输出的就是varying变量vLightWeighting,用于在片元着色器中调整图像的颜色。如果光照被关闭,我们就使用默认值(1,1,1),意思是说物体颜色将不会被改变。如果光照开启,我们使用法线矩阵计算出法线方向,然后计算它与光线方向向量的点积,用于表示反射了多少光(当然,最小值是0,就像我们之前说的)。最终我们用平行光的颜色分量乘以这个反射了多少光的量得出最终的光亮程度,然后加上环境光的颜色。而运算结果正是片元着色器需要的。到此,我们完成了光照!
在本节课中,你已经对于理解在像WebGL这种图形系统中光线是如何工作的有了扎实的基础,并且知道了如何设置两种简单的光——环境光和平行光。
点光源
转自: http://www.hiwebgl.com/?p=355
首先,让我们来解释一下用点光源做些什么;点光源与平行光源之间的不同在于点光源中的光线来自场景中的一个点。仔细想想你就能搞清楚,这意味着,对于场景中的每一个点,每束光线射入的角度都是不同的。所以,处理它的最好方法就是计算每个顶点朝着光线所在位置的方向,接着进行我们对平行光线所做的同样的运算。这就是我们所要做的。
(在这里你们可能会想,计算顶点之间的点的光的方向 – 也就是计算每个片元上光的方向,会比仅仅计算每个顶点上的光的方向来的更好。没错,这样想是正确的。这样的计算方法对于显卡来说,比较困难,但是,它给出的效果会更好。在下一节课,我将会详细说明)
现在,既然我们知道了我们需要做的是什么,我们可以再回到示例页面看一下,你会发现在场景中,射出光源的那个点上没有一个实际的物体。如果你希望有一个看上去发出光源的物体(例如在太阳系中的太阳),你就需要分别定义光源和物体。根据我们前几课所学,绘制一个新物体现在对我们是来说十分简单了。所以在教程中,我仅仅会介绍点光源是如何作用的。在以上的说明中你一定可以看出整个过程十分简单;和第十一课中所述内容的最大不同在于需要绘制一个立方体,并且让立方体和球体一起沿着轨道转动。
和以前一样我们从源代码底部向上看起,找出本课代码与第十一课代码中的不同。第一个不同在于HTML的 body标签中,这里用来输入光源方向的字段已经改为光源位置。这个改动十分简单,所以就不再解释了。接着让我们来看一下webGLStart函数。这里的改动依然很简单,这节课上,我们不会用到鼠标控制,所以我们也不需要这部分代码;并且,之前称作initTexture的函数现在被更名为initTextures,这是因为函数需要同时加载一个立方体和一个球体。很无聊的理由吧……
检查光线复选框是否被勾选,并且将环境光的颜色传递给显卡:
454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 |
function drawScene() { gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); pMatrix = okMat4Proj(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0); var lighting = document.getElementById("lighting").checked; gl.uniform1i(shaderProgram.useLightingUniform, lighting); if (lighting) { gl.uniform3f( shaderProgram.ambientColorUniform, parseFloat(document.getElementById("ambientR").value), parseFloat(document.getElementById("ambientG").value), parseFloat(document.getElementById("ambientB").value) ); |
然后,我们通过uniform变量将点光源的位置推送给显卡。这里的代码和我们在11课中用来推送光源方向的代码是完全一样的,不同之处在于我们去掉了一些东西,而不是加入了一些新的东西。在第11课中,当我们把光源方向发送给显卡时,我们需要将它转化为一个单位矢量 (也就是说,将它的长度缩放为1)并且将它的方向倒转。但对于点光源来说,我们不需要这样做,我们只需要直接将光源坐标传递给显卡就行。
470 471 472 473 474 475 |
gl.uniform3f( shaderProgram.pointLightingLocationUniform, parseFloat(document.getElementById("lightPositionX").value), parseFloat(document.getElementById("lightPositionY").value), parseFloat(document.getElementById("lightPositionZ").value) ); |
接着,我们针对点光源的颜色做相同的操作,drawScene中的光源代码大概就是这样了。
477 478 479 480 481 482 483 |
gl.uniform3f( shaderProgram.pointLightingColorUniform, parseFloat(document.getElementById("pointR").value), parseFloat(document.getElementById("pointG").value), parseFloat(document.getElementById("pointB").value) ); } |
接着往上,我们将会遇到最后一个,也是最重要的一个变化。找到顶点着色器的部分,你会发现这里有一些小的变动。我们从上往下看,注意发生变动的代码位于第35行和第36行。
25 26 27 28 29 30 31 32 33 34 35 36 |
attribute vec3 aVertexPosition; attribute vec3 aVertexNormal; attribute vec2 aTextureCoord; uniform mat4 uMVMatrix; uniform mat4 uPMatrix; uniform mat4 uNMatrix; uniform vec3 uAmbientColor; uniform vec3 uPointLightingLocation; uniform vec3 uPointLightingColor; |
这样我们就设置好了光源位置和颜色的uniform变量,用来替换原先的光照方向和颜色。接下来:
38 39 40 41 42 43 44 45 |
uniform bool uUseLighting; varying vec2 vTextureCoord; varying vec3 vLightWeighting; void main(void) { vec4 mvPosition = uMVMatrix * vec4(aVertexPosition, 1.0); gl_Position = uPMatrix * mvPosition; |
我们在这里所做的是将原先的代码分成两个部分。到目前为止,在我们对顶点着色器的所有的应用中,我们都是一次性把模型视图矩阵和投影矩阵配置在顶点位置中,就像下面这样:
1 2 |
// Code from lesson 11 gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0); |
但是在这里,我们将会储存一个中间值,这个顶点位置的值已经应用了当前模型视图矩阵,但是还未根据透视进行调整。在下面的代码中我们用到了这个值。
46 47 48 49 50 51 |
vTextureCoord = aTextureCoord; if (!uUseLighting) { vLightWeighting = vec3(1.0, 1.0, 1.0); } else { vec3 lightDirection = normalize(uPointLightingLocation - mvPosition.xyz); |
光源位置是通过世界空间坐标表现的,顶点坐标也是一样,模型视图矩阵乘以顶点坐标以后,也将会通过世界空间坐标表现。我们需要利用这些坐标算出点光源射入当前顶点的方向,还需要算出一点到另一点的方向,我们要做的仅仅是把他们相除。除过之后,我们需要做的是使方向向量归一化,就像我们之前对平行光的矢量所做的一样,把它的长度调整为1。 设置完成后,将所有的东西组合起来进行一次运算,这和我们对平行光源所做的是完全一样的,只有几处变量名称的变化。
53 54 55 |
vec3 transformedNormal = (uNMatrix * vec4(aVertexNormal,1.0)).xyz; float directionalLightWeighting = max(dot(transformedNormal, lightDirection), 0.0); vLightWeighting = uAmbientColor + uPointLightingColor * directionalLightWeighting; |
好了,现在你学会了如何在着色器中编写代码来创建一个点光源。
phone shader:
转自: http://www.hiwebgl.com/?p=363
两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;或者点击这里下载我们为您准备好的压缩包。
让我们先讲一下,耗费更多的显卡资源去实现逐片元光照为什么是值得的。你也许还记得左边这张在第7课出现过的图片。你已经知道,物体表面的光亮程度是由法线和入射光线之间的夹角决定的。到目前为止,我们的光照效果都是在顶点着色器中计算的,其中用到了每个顶点的法线和光照方向。这中间有一个称之为光照量的参数,也就是物体表面反射了多少光。我们把这个参数以varying变量的形式,从顶点着色器传递到片元着色器,用来调整片元的颜色,反映出相应的光照程度。这个光照量的参数,和其他varying变量一样,对于顶点之间的片元,都会被WebGL进行线性插值。所以,在左边的图中,B点将会相当明亮,因为B点的光线几乎是平行于法线的;而A点则会稍微暗一些,因为光线的入射角更大一些。在A点和B点之间的点,将会有一个从明到暗的渐变。这个效果看起来非常好。
让我们把光源的位置往上提提,就像右图中的那样。A点和C点都会比较暗,因为光线的入射角更大。假设我们仍然使用逐顶点光照,那么B点的明亮程度应该是A点和C点的平均值,所以B点也同样会比较暗。但是,这很明显是错误的!在B点,光线几乎是平行于法线的,所以它应该是其中最明亮的一个点。所以在计算顶点之间的片元光照时,我们必须逐个片元、逐个片元的单独进行计算。
每个片元都要计算各自的光照效果,意味着我们需要它们各自的位置(用于计算光照方向)和各自的法线;我们可以把这些值都从顶点着色器传递到片元着色器。同样它们也会被线性插值,所以位置值会是一条顶点间的直线,而法线值将会平滑地改变。那条直线正是我们想要的;而A点和C点的法线是相同的,所以这两点之间所有片元的法线也是相同的,这也很完美。
这样就解释了为什么在我们的页面上,启用逐片元光照后立方体看起来更加真实。另外还有一个好处,那就是对于用平面图形逼近组成的弯曲表面,例如球体,它也能给出很好的光照效果。因为如果两个顶点的法线不同,那么对于这两个顶点之间的片元来说,线性插值后的法线也会平滑的改变,这样就实现了曲面效果。在我们的这种思考方式中,逐片元光照实际上是冯氏着色法(Phong shading)的一种形式,下面的这幅图很好的解释了这种效果,我就不用再多费口舌了。
你同样也可以在Demo中观察到这种效果。当你关闭逐片元光照,启用逐顶点光照时,你会发现阴影的边缘(也就是点光源失效,完全被环境光接管的部分)看起来有很多锯齿。这是因为球体是由很多三角形组成的,你可以仔细观察它们的边缘。当你开启逐片元光照时,你会发现边缘的这种过度非常平滑,更像是真正的球体上的效果。
好了,以上就是理论部分,现在让我们看一下代码吧。着色器部分的代码在页面的顶部,我们先来看一下它们。因为在这节课中我们既要用到逐顶点光照又要用到逐片元光照,这是由页面上的复选框决定的,所以每个光照技术都需要有各自的顶点着色器和片元着色器(将两者都写到一起也是可能的,但是这会导致代码很难读)。我们稍后再讲切换不同光照技术的方法,现在你只要记住在页面中我们是通过定义脚本中的id标签来区别二者的。首先是用于逐顶点光照的一对着色器,它们和第7课中的代码完全一样,所以我只列出脚本标签,注意看其中id。
8 |
<script id="per-vertex-lighting-fs" type="x-shader/x-fragment"> |
31 |
<script id="per-vertex-lighting-vs" type="x-shader/x-vertex"> |
然后是逐片元光照的片元着色器。
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
<script id="per-fragment-lighting-fs" type="x-shader/x-fragment"> precision mediump float; varying vec2 vTextureCoord; varying vec3 vTransformedNormal; varying vec4 vPosition; uniform bool uUseLighting; uniform bool uUseTextures; uniform vec3 uAmbientColor; uniform vec3 uPointLightingLocation; uniform vec3 uPointLightingColor; uniform sampler2D uSampler; void main(void) { vec3 lightWeighting; if (!uUseLighting) { lightWeighting = vec3(1.0, 1.0, 1.0); } else { vec3 lightDirection = normalize(uPointLightingLocation - vPosition.xyz); float directionalLightWeighting = max(dot(normalize(vTransformedNormal), lightDirection), 0.0); lightWeighting = uAmbientColor + uPointLightingColor * directionalLightWeighting; } vec4 fragmentColor; if (uUseTextures) { fragmentColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t)); } else { fragmentColor = vec4(1.0, 1.0, 1.0, 1.0); } gl_FragColor = vec4(fragmentColor.rgb * lightWeighting, fragmentColor.a); } </script> |
你会发现其中的代码和我们之前一直在顶点着色器中写的代码非常相似。实际上,根本也是做的完全一样的工作,计算出光线方向,然后与法线结合在一起,计算出光照亮。区别在于,参与运算的这些值是从varying变量中引入的,而不是顶点属性;并且最后的光照亮会立即与纹理材质结合,而不是先输出再稍后处理。值得注意的是,我们必须将线性插值之后的法线进行归一化处理。归一化,就是将一个向量的长度调整为1。这是因为在两个长度为1的向量间进行插值,仅仅是确保插值后的向量指向正确的方向,而不会确保长度依然为1。所以我们必须对其进行归一化处理。
因为所有的重担都由片元着色器承担了,所以在逐片元光照中,顶点着色器的代码相对比较简单。
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
<script id="per-fragment-lighting-vs" type="x-shader/x-vertex"> attribute vec3 aVertexPosition; attribute vec3 aVertexNormal; attribute vec2 aTextureCoord; uniform mat4 uMVMatrix; uniform mat4 uPMatrix; uniform mat4 uNMatrix; varying vec2 vTextureCoord; varying vec3 vTransformedNormal; varying vec4 vPosition; void main(void) { vPosition = uMVMatrix * vec4(aVertexPosition, 1.0); gl_Position = uPMatrix * vPosition; vTextureCoord = aTextureCoord; vTransformedNormal = (uNMatrix * vec4(aVertexNormal, 1.0)).xyz; } </script> |
在应用了模型视图矩阵并和法线矩阵相乘之后,我们依然需要计算出顶点的位置。但是我们只需要把它们储存到varying变量中就可以了,稍后在片元着色器中会用到。
以上就是着色器中的全部代码了!剩下的代码和之前的课程中的非常类似,除了一个地方。到目前为止,我们在一个WebGL页面中只使用了1个顶点着色器和1个片元着色器。现在你也许还记得在第1课中我们提到过,WebGL program对象是用来把着色器代码传送到显卡端的,一个program对象只能同时包含1个顶点着色器和1个片元着色器。这也就是说,我们需要定义两个program对象,然后根据是否开启逐片元光照的复选框来切换它们。
方法很简单,initShaders函数做出了如下变化:
222 223 224 225 226 227 228 229 |
var currentProgram; var perVertexProgram; var perFragmentProgram; function initShaders() { perVertexProgram = createProgram("per-vertex-lighting-fs", "per-vertex-lighting-vs"); perFragmentProgram = createProgram("per-fragment-lighting-fs", "per-fragment-lighting-vs"); } |
我们定义了两个独立的全局变量,一个用来储存逐顶点光照,另一个用来储存逐片元光照;另外还有一个currentProgram变量用来表示当前正在使用的光照。createProgram函数和我们之前用的那个是一样的,只不过被参数化了,我就不重复解释了。
然后我们在drawScene函数一开始就切换到相应的program。
534 535 536 537 538 539 540 541 542 543 544 545 546 |
function drawScene() { gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); pMatrix = okMat4Proj(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0); var perFragmentLighting = document.getElementById("per-fragment").checked; if (perFragmentLighting) { currentProgram = perFragmentProgram; } else { currentProgram = perVertexProgram; } gl.useProgram(currentProgram); |
我们必须一开始就做这项工作是因为,当我们在编写绘制的代码时(比如设置uniform变量或者将顶点数组绑定到attribute上),我们需要确定当前program是哪一个,否则我们就可能使用了错误的program。
548 549 |
var lighting = document.getElementById("lighting").checked; gl.uniform1i(currentProgram.useLightingUniform, lighting); |
这样,你应该能看出来,每次我们调用drawScene函数使用了并且仅使用了一个program;在不同的调用中可能会用到不同的program。你也许会问,是否可以在drawScene函数内部的不同地方使用不同的program?那么场景中不同的部分就会用不同的着色器绘制,比如说也许你的场景中有一部分需要用逐片元光照,另一部分需要用逐顶点光照。答案是肯定的!尽管在本课中不需要这么做,但是,它是完美可行的并且实际上非常有用!