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

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

欢迎来到WebGL教程的第14课。在这节课中,我们将会引入从第7课开始介绍的冯氏反射模型中的最后一个部分:镜面高光——在一个光泽表面上闪光的部分。镜面高光会让你的场景看起来更加真实。

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

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

你会看到一个旋转的“犹他茶壶”,旋转的同时你会看到在茶壶中部偏左的部分和茶壶盖子的帽上会有常亮的高光,另外当茶壶的壶嘴和把手与光源成特定角度时,偶尔也会有高光出现。你可以使用canvas下面的复选框来切换是否开启镜面高光,是否开启光源、切换3个不同的纹理状态,分别是不使用纹理、默认的电镀金属纹理(根据CC协议,感谢Arroway Textures提供),最后为了好玩,我们还加上了一个地球的纹理(感谢European Space Agency/Envisat提供),不过茶壶形状的地球看起来实在是太猎奇了。

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

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

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

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

得到了源代码之后,请在编辑器中打开。这次我们从代码顶部开始,然后顺着慢慢往下看。这样的好处是我们可以先看到本科中最有趣的部分,也就是片元着色器。在开始之前,还要说一下本课代码和第13课中的一个区别,那就是我们摒弃了逐顶点光照的代码。说实话,逐顶点光照不能很好处理镜面高光(因为看起来非常斑驳),所以我们不再使用它了。

好了,首先你会看到逐片元光照的片元着色器代码。和通常一样,代码先定义了浮点级别的精确度,声明了varying变量和uniform变量。在uniform变量中,我们将点光源的颜色分成了2个变量来分别储存,一个是漫反射的颜色,另一个是镜面高光的颜色。请注意第17行和第26、27行。

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 
    precision mediump float;

    varying vec2 vTextureCoord;
    varying vec3 vTransformedNormal;
    varying vec4 vPosition;

    uniform float uMaterialShininess;

    uniform bool uShowSpecularHighlights;
    uniform bool uUseLighting;
    uniform bool uUseTextures;

    uniform vec3 uAmbientColor;

    uniform vec3 uPointLightingLocation;
    uniform vec3 uPointLightingSpecularColor;
    uniform vec3 uPointLightingDiffuseColor;

    uniform sampler2D uSampler;

没必要解释太多,你可以在页面中手动设定它们的值。让我们接着往下看着色器部分的主函数,首先要做的事情就是判断光照是否开启,这段代码和之前的一样。

32
33
34
35
36
    void main(void) {
        vec3 lightWeighting;
        if (!uUseLighting) {
            lightWeighting = vec3(1.0, 1.0, 1.0);
        } else {

下面我们开始处理光照,好戏就要上演了!

37
38
39
40
41
            vec3 lightDirection = normalize(uPointLightingLocation - vPosition.xyz);
            vec3 normal = normalize(vTransformedNormal);

            float specularLightWeighting = 0.0;
            if (uShowSpecularHighlights) {

这儿到底怎么了呢?和往常一样,我们为逐片元光照计算出光照方向;然后将片元的法线向量归一化,这还是和往常一样——切记,顶点法线在经过线线性插值之后,得到的片元法线的长度会改变,所以我们必须进行归一化处理!但是这次,我们会比较多地使用归一化之前的线性插值的结果,所以我们将其储存在一个本地变量中。然后,我们定义了一个变量用来储存由镜面高光所带来的额外的亮度。如果镜面高光没有开启,显然它的值应当是0;反之,我们就要计算它了。

那么,究竟是什么决定了镜面高光的亮度呢?你也许还记得,在第7课中我们解释冯氏反射模型的时候曾经说过,镜面高光其实就是光线像照到镜子上一样,照到物体表面然后反射出来的部分。

镜面反射(Specular):这就像镜子一样,反射光将按照和入射角相同的角度反射出来。这种情况下,你看到的物体反射出来的光的亮度,取决于你的眼睛和光反射的方向是否在同一直线上;也就是说,反射光的亮度不仅与光线的入射角有关,还与你的视线和物体表面之间的角度有关。镜面反射通常会造成物体表面上的“闪烁”和“高光”现象,镜面反射的强度也与物体的材质有关,无光泽的木材很少会有镜面反射发生,而高光泽的金属则会有大量镜面反射。

计算镜面反射亮度的方程式如下:

  • (Rm · V)α

其中Rm是发生镜面反射时反射光的方向的单位向量,V是观察方向的单位向量,α是一个描述光泽度的常量,常量的值越大,光泽度越高。你也会还记得,两个向量的点积就是它们之间夹角的余弦值。这样的话,如果光线直接反射到观察者的眼睛中,那根据方程式计算出的结果是1(也就是说Rm和V是平行向量,夹角为0,而0的余弦是1),然后随着光线与视线方向的夹角慢慢变大,镜面反射的亮度也会慢慢地进行一个渐变。当施加α次幂之后,实际上会“压缩“镜面高光的效果,在某个点上,当两个向量平行时,计算结果仍然是1,但该点周围的亮度会迅速下降。你可以试着在Demo页面中调整光泽度常量的值,调的大一些,比如512,你就会明白了。

好了,有了以上的准备,我们首先要做的就是计算出观察方向V和反射光方向Rm。让我们先来看看看V,因为它很简单。在第10课中提到过,我们的场景是基于eye space构造的。也就是说,我们的相机位于原点(0,0,0),看向Z轴的负半轴,X轴的坐标越往右越大,Y轴的坐标越往上越大。从原点到空间中的任何一点的方向,就等于该点的坐标值所表示的向量。那么同样的,反过来,从任何一点看向原点的观察方向,就是该点坐标值的负值。我们对顶点坐标进行线性插值,得到了片元的坐标,储存在vPosition变量中,然后对其取负值,然后归一化使其长度为1,这样我们就得到了观察方向的单位向量V。

42
                vec3 eyeDirection = normalize(-vPosition.xyz);

让我们再来看看Rm。这里原本应该是很麻烦的,但幸好我们有一个GLSL函数reflect,这就方便多了。这个函数是这样定义的:

reflect (I, N)
其中I是入射向量,N是物体表面朝向,函数返回值是反射方向。

入射向量就是光线照在物体表面上的入射方向,也就是从片元到光线的方向的反方向,而片元到光线的方向我们已经有了,储存在lightDirection中。N,也就是物体表面朝向,实际上就是法线,我们也已经计算出了。这样,我们就可以很轻松的计算出反射方向。

43
                vec3 reflectionDirection = reflect(-lightDirection, normal);

好了,万事俱备,最后一步非常简单。

45
46
                specularLightWeighting = pow(max(dot(reflectionDirection, eyeDirection), 0.0), uMaterialShininess);
            }

以上就是我们如何计算镜面反射贡献的亮度,然后我们再来看看漫发射贡献的亮度。我们还是用之前的逻辑(这里我们用本地变量来储存归一化的法线向量)。

48
            float diffuseLightWeighting = max(dot(normal, lightDirection), 0.0);

最后,我们将镜面反射的亮度、漫反射的亮度和环境光的颜色结合起来,计算出片元的总亮度。计算方法是我们之前使用的方法的扩展。

49
50
51
52
            lightWeighting = uAmbientColor
                + uPointLightingSpecularColor * specularLightWeighting
                + uPointLightingDiffuseColor * diffuseLightWeighting;
        }

完成了以上工作,我们就可以使用与第13课中完全相同的代码来计算,基于当前纹理,每个片元的颜色。

54
55
56
57
58
59
60
61
        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);
    }

到这里,片元着色器就工作完毕。

再往下看看,你会发现下一个与第13课不同的地方,位于initShaders函数中,这个函数又回到了更早之前的老样子,非常简单。其中只创建了一个program,然后理所当然的,为新增加的镜面高光初始化了一两个新的uniform地址。再往下一点,initTextures函数载入了地球纹理和电镀金属纹理,而不是什么月球和木质的板条箱了。再往下,在setMatrixUniforms函数中,和initShaders一样,又回到的原来的样子,只涉及到一个program,而不是多个。再往下,我们就到了本节课中另一个比较有趣的地方了。

我们不再使用initBuffers函数来创建包含不同的顶点属性用于定义茶壶外观的WebGL数组对象了,我们有了两个新朋友,handleLoadedTeapot和loadTeapot函数。代码的模式看起来很像第10课中载入世界的代码,但是还是值得再重新看一遍的。让我们先来看看loadTeapot函数(虽然在代码中它是其中第二个函数)。

284
285
286
287
288
289
290
291
292
293
    function loadTeapot() {
        var request = new XMLHttpRequest();
        request.open("GET", "Teapot.json");
        request.onreadystatechange = function () {
            if (request.readyState == 4) {
                handleLoadedTeapot(JSON.parse(request.responseText));
            }
        }
        request.send();
    }

代码的总体结构看起来和第10课很像,我们创建了一个新的XMLHttpRequest对象,然后用它来载入Teapot.json文件。因为是异步加载,所以我们还加入了一个回调函数,它会在载入文件过程中的不同阶段被触发,然后当readyState等于4时,也就是说文件被完全载入了,我们做一下相应的处理。

接下来发生的事情非常有意思。我们载入的文件是JSON格式的,基本上也就是说,它是已经用Javascript写好的,打开这个文件看看你就明白我说的意思了。文件描述了一个Javascript对象,其中的列表储存了组成茶壶所需的顶点位置、法线、纹理坐标和顶点索引。我们当然可以将这些代码直接写到index.html里面,但是当你构建更复杂的模型时,尤其是由不同的独立模型的物体组成的,你最好还是把它们都放到一个单独的文件中。

至于具体使用什么样的格式来储存模型,这是个很值得探讨的问题。你可以在任何你喜欢的软件中建模,这些软件可以将模型导出为不同的格式,比如3DsMax中的.obj文件。在未来,也许一些软件能够将模型导出为在Javascript中可以直接使用的格式,就好像我们这节课中用来储存茶壶模型的JSON文件。那下面,你应当将本教程仅仅作为一个示例,展示了如何载入一个预先设计好的JSON模型文件,而不是一个最好的典范。

250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
    var teapotVertexPositionBuffer;
    var teapotVertexNormalBuffer;
    var teapotVertexTextureCoordBuffer;
    var teapotVertexIndexBuffer;

    function handleLoadedTeapot(teapotData) {
        teapotVertexNormalBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexNormalBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexNormals), gl.STATIC_DRAW);
        teapotVertexNormalBuffer.itemSize = 3;
        teapotVertexNormalBuffer.numItems = teapotData.vertexNormals.length / 3;

        teapotVertexTextureCoordBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexTextureCoordBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexTextureCoords), gl.STATIC_DRAW);
        teapotVertexTextureCoordBuffer.itemSize = 2;
        teapotVertexTextureCoordBuffer.numItems = teapotData.vertexTextureCoords.length / 2;

        teapotVertexPositionBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexPositionBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexPositions), gl.STATIC_DRAW);
        teapotVertexPositionBuffer.itemSize = 3;
        teapotVertexPositionBuffer.numItems = teapotData.vertexPositions.length / 3;

        teapotVertexIndexBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, teapotVertexIndexBuffer);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(teapotData.indices), gl.STATIC_DRAW);
        teapotVertexIndexBuffer.itemSize = 1;
        teapotVertexIndexBuffer.numItems = teapotData.indices.length;

        document.getElementById("loadingtext").textContent = "";
    }

这些代码里实在是没有什么好强调的东西。就是从载入的JSON对象中提取不同的列表,然后放到WebGL的数组对象中,然后推送到显卡端。在这之后,我们清空了HTML中的div标签,和第10课一样,它用来告诉用户模型正在载入中。

好了,模型载入完毕了,还有什么要说的嘛?好吧,还有drawScene函数。在确认模型已经载入之后,我们要在一个合适的角度来绘制茶壶,这里没什么新东西。看一下代码,确认你知道其中发生了什么(如果有任何不明白的地方,请留下评论),不过我想你应该找不出什么让你吃惊的地方。

这之后,animate函数里也有一些琐碎的改变,用来让茶壶旋转而不是什么月亮和箱子;webGLStart函数调用了loadTeapot而不是initBuffers。最后,当载入模型时,HTML里的div标签中会显示“Loading world…”,还有相应的CSS样式。当然,还新增加了一个文本域用来输入新的镜面高光的相关参数的。

好了,本节课到这里就结束了。你学会了如何实现镜面高光,如何载入一个储存在JSON文件中预先制作好的模型。下节课我们将会学习一个稍微有点高端的东西,这是一种不同的、更加有趣的使用纹理的方式——高光贴图。

发表评论

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