HiWebGL译者声明:因为译者个人方便的原因,我们将原教程中的第三方图形库由glMatrix改为Oak3D实现,这不影响到Demo的最终效果和实现,也不影响到WebGL的讲解和学习。原教程正文中相应的代码和讲解也为做了相应修改!本教程由HiWebGL翻译整理,转载请注明出处!

关于Oak3D:Oak3D是一套简单易用、性能优越的WebGL Javascript Library。您可以在他们的主页找到更多信息。Oak3D主页:http://www.oak3d.com 

欢迎来到WebGL教程的第七课,本节课的内容是根据NeHe的OpenGL教程的第七节改编的。在这节课中我们将为你的WebGL页面添加简单的光照。说实话,这要比在OpenGL中更复杂一些,希望本教程能让你轻松理解其中的原理。

下面的视频就是我们这节课将会完成的最终效果。

点击这里打开一个独立的WebGL页面,如果你的浏览器不支持WebGL,请点击这里

你可以用canvas下面的复选框来选择开启或者关闭光照,这样你就能看出效果上的变化。你还可以改变平行光和环境光的颜色(稍后我会详细讲解),另外还可以设置平行光的方向。在开始之前,请先试着玩一下这个Demo,例如更改平行光的RGB值,改为大于1的某个值(不过如果超过5的话,你会发现损失了一定的纹理细节)。另外,和上节课一样,你依然可以用方向键控制盒子的旋转快慢,用PageUp和PageDown键来进行缩放。这次我们使用了效果最好的纹理过滤,所以去掉了F键的功能。

下面我们来看看它是怎么工作的……

惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。

另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。

有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;或者点击这里下载我们为您准备好的压缩包。

在我们详细讲解如何在WebGL中添加和设置光照之前,我需要先宣布一个坏消息!那就是,WebGL完全没有提供任何对光照系统的内置支持!不像OpenGL,它的实现版本最少也允许你指定8道光源,并且可以替你处理这些光源,而WebGL却完全撒手不管,都推给了你自己解决。但是——这是一个转折度很深的“但是”——如果解释清楚了,其实光照是一个相当简单的概念。如果你能很好的理解我们之前讲过的着色器的知识,那学习光照应该也没什么问题——只有你像新手一样从编写简单的光照代码开始,才会让你更容易理解当你成为高手后必须去面对的那些代码。毕竟,在OpenGL中光照是描绘真实场景时最基本的元素,然而它却并不能处理阴影,比如说,对于弯曲表面它产生的效果非常粗糙。所以稍微复杂一些的场景中的光照都需要手工编写代码。

好了,让我们先来想一下我们想从光照中得到什么效果。我们的目的是在场景内模拟出几道光源。这些光源本身不需要是可见的,但它们发出的光必须能够作用于3D物体的表面,让物体面对光源的那一侧显得明亮,同时背对光源的部分变得阴暗。换句话说,我们想要指定一系列的光源,然后让这些光源可以作用于我们的3D场景内的任何一个部分。现在,我想你应该已经知道WebGL是通过向着色器中填充一些东西来实现这些效果的。更进一步的说,我们这节课中要做的就是向顶点着色器写入代码来处理光照。我们将要计算出光照对每个顶点的影响,并以此来调整顶点的颜色。虽然现在我们只准备计算一道光源,但计算多道光源也不复杂,只是重复这一过程然后把结果累加在一起。

还需要说明的是,我们只是基于每个顶点来处理光照效果,所以顶点间像素的光照效果都是由线性插值来实现的。也就是说,物体顶点间的部分都会被当成平面来处理,而碰巧的是,我们画的是一个立方体,这正是我们想要的!对于弯曲的表面,如果你想为每个像素都独立计算出光照效果,你可以使用一种称之为“逐像素光照(逐片元光照)”(per-pixel lighting或per-fragment lighting)的技术,它能实现更好的效果。我们将在以后的课程中学习这项技术。而现在我们做的逻辑上被称为“逐顶点光照”(per-vertex lighting)。

好了,下一步,如果我们要向顶点着色器写入代码来处理一道光源对顶点颜色的影响,那我们应该怎么做呢?让我们先从冯氏反射模型(Phong Reflection Model)开始。首先你要理解下面几点:

  • 虽然在真实世界里不存在光照类型的概念,但在图形学中我们却将光的类型按照光与物体表面的作用来进行了区分:
    1. 一种是从特定方向射入并只会照亮面对入射方向的物体,我们称之为平行光(directional light)。
    2. 另一种光是来自所有方向并且会照亮所有物体,不管这些物体的朝向如何,我们称之为环境光(ambient light)。当然在真实世界里,这只是平行光照到其他物体上,比如空气、灰尘等等,然后反射出来的散射而已。但是在这里,我们需要把它单独作为一个光照模型列出来。
  • 当光照到物体表面,会发生两种情况:
    1. 漫反射(Diffuse):无论光的入射角度如何,都会向所有方向发生反射。反射光的亮度只和光线的入射角度有关,与观察角度无关。光线越平行于物体表面,则反射光越弱,表面越暗;光线越垂直于表面,反射光越强,表面越亮。漫反射是我们通常想到一个物体受到光照时需要首先想到的。
    2. 镜面反射(Specular):这就像镜子一样,反射光将按照和入射角相同的角度反射出来。这种情况下,你看到的物体反射出来的光的亮度,取决于你的眼睛和光反射的方向是否在同一直线上;也就是说,反射光的亮度不仅与光线的入射角有关,还与你的视线和物体表面之间的角度有关。镜面反射通常会造成物体表面上的“闪烁”和“高光”现象,镜面反射的强度也与物体的材质有关,无光泽的木材很少会有镜面反射发生,而高光泽的金属则会有大量镜面反射。

冯氏反射模型引申了这个四步走的光照系统,首先所有的光线都有以下两个属性:

  1. 发生漫反射光的RBG值。
  2. 发生镜面反射光的RGB值。

其次所有材质都有以下四个属性

  1. 反射的环境光RGB值
  2. 反射的漫反射光RGB值
  3. 反射的镜面反射光RGB值
  4. 物体的反光度,它决定了镜面反射的细节

对于场景中的每一点,它的颜色都是由照射光的颜色、材质本身的颜色和光照效果混合起来的。所以,根据冯氏反射模型,为了解决场景中的光照,每条光线我们都需要知道两个属性,每个物体表面上的点都需要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这种图形系统中光线是如何工作的有了扎实的基础,并且知道了如何设置两种简单的光——环境光和平行光。

如果你有任何问题、评论或者修正,都请留下评论告诉我!

下一节课,我们将来研究一下混合,它将用于绘制部分透明的物体。

发表评论

邮箱地址不会被公开。 必填项已用*标注