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

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

欢迎来到WebGL教程的第六课,这节课的内容是基于NeHe OpenGL教程的第七节改编的。在这节课上,我们将会介绍如何在WeblGL页面上实现键盘输入。我们将用键盘输入来控制贴上纹理贴图的立方体的旋转方向和旋转速度;并且,我们还可以改变纹理过滤的方式,你可以选择加载速度快但图像质量低,或者加载速度慢但图像质量高的表现形式。在NeHe的OpenGL教程的第七节中,不光介绍了这些,还包括光线;因为光线在WebGL中比OpenGL有更多的作用,所以我在这节课上先不讲,以后拿出来单独讲。

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

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

加载完成后,你可以使用Page Up 和 Page Down键来进行缩放;你还可以用方向键来改变立方体的旋转方向(按下的时间越长,旋转的速度越快)。你还可以点击F键在三个不同的纹理过滤之间切换。

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

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

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

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

这节课和之前最大的不同在于,我们需要把重心转到键盘上,我们先来看一看代码,这样的话,比较容易理解我们的课程。在示例代码中,你会发现我们定义了如下的全局变量:

192
193
194
195
196
197
198
199
200
    var xRot = 0;
    var xSpeed = 0;

    var yRot = 0;
    var ySpeed = 0;

    var z = -5.0;

    var filter = 0;

在第五课上,我们已经对xRot和yRot很熟悉了,他们代表立方体沿X轴和Y轴的旋转。xSpeed和ySpeed就很明显了,我们将允许用户通过方向键来改变立方体的旋转速度,而对xRot和yRot变量的改变速率就需要储存在xSpeed和ySpeed中。z是立方体的z轴,就是它离浏览者的距离,是通过Page Up 和Page Down控制的。最后,filter是一个从0到2之间的整数,它指明了我们在立方体上覆盖的纹理的过滤方式,这些过滤方式决定了纹理图像的图形质量如何。

我们来看一下驱动纹理过滤的代码。第一处的改变在于加载纹理的代码,这段代码位于示例代码自上到下三分之一处。这一部分代码和以前有很大不同,因此,我就不标红任何东西了。但是,你们应该还是十分熟悉这样的代码形式。

120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
    function handleLoadedTexture(textures) {
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

        gl.bindTexture(gl.TEXTURE_2D, textures[0]);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[0].image);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

        gl.bindTexture(gl.TEXTURE_2D, textures[1]);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[1].image);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

        gl.bindTexture(gl.TEXTURE_2D, textures[2]);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[2].image);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
        gl.generateMipmap(gl.TEXTURE_2D);

        gl.bindTexture(gl.TEXTURE_2D, null);
    }

    var crateTextures = Array();

    function initTexture() {
        var crateImage = new Image();

        for (var i=0; i < 3; i++) {
            var texture = gl.createTexture();
            texture.image = crateImage;
            crateTextures.push(texture);
        }

        crateImage.onload = function () {
            handleLoadedTexture(crateTextures)
        }
        crateImage.src = "crate.gif";
    }

我们首先来看一下函数initTexture 和全局变量crateTextures。我们很明显可以看出,虽然代码已经发生变化,但是代码中根本的不同在于我们创建了3个WebGL纹理对象数组,而不是一个;并且,当图像被加载时,我们通过回调函数将这个数组传递到handleLoadedTexture。当然,我们需要加载一个新的图像,crate.gif而不是nehe.gif。

handleLoadedTexture 也并没有发生任何复杂的变化。之前,我们只是在初始化了一个带有图像数据的WebGL纹理物体,并且设置了两个参数gl.TEXTURE_MAG_FILTER 和gl.TEXTURE_MIN_FILTER ,他们都被设置为 gl.NEAREST。现在,我们要初始化数组中的3个带有相同图像的纹理,但是各自的参数是不同的,并且最后一个纹理还附加了一些其他的代码。以下就是这些纹理过滤方式具体是在哪些方面不同的。

Nearest(最近点采样过滤)

第一个纹理的gl.TEXTURE_MAG_FILTER 和gl.TEXTURE_MIN_FILTER参数都被设置为gl.NEAREST。这个和我们原来的设置是一样的,也就是说,当纹理被按比例放大或者缩小时,WebGL会在原始图像上寻找最近的点来决定指定点的颜色。在没有缩放的情况下,纹理看其来还是不错的;缩小后,图像看起来还过得去;但是纹理图像被放大时,看起来会有很多“马赛克”,因为这种算法实际上只是简单地放大了原始图像的像素,并没有做其他任何优化。

Linear(线性过滤)

对于第二个纹理来说,gl.TEXTURE_MAG_FILTER 和gl.TEXTURE_MIN_FILTER参数都被设置为gl.LINEAR。这里,我们在放大缩小时还是使用同样的过滤方式。但是,线性算法能够在纹理被放大时更好的表现物体;基本可以说,它对原始纹理图像上的像素进行了线性插值。说的再大概一下,在一个白的和一个黑的像素之间的像素会被输出为灰色。这样的话,我们看到的画面效果就更加平滑,但是必然原本锐利的边缘部分会看起来有点模糊。(公平的说,放大图像时,图像看起来都不会那么完美,因为你无法看到原始图像中本来就没有的细节。)

Mipmaps(多级渐进纹理过滤)

对于第三个纹理来说,gl.TEXTURE_MAG_FILTER参数被设置为gl.LINEAR,而gl.TEXTURE_MIN_FILTER被设置为gl.LINEAR_MIPMAP_NEAREST。这个是三种纹理过滤方式中最复杂的一个。

Linear过滤在图像被放大时,给出了比较好的效果,但是在图像被缩小时,它的表现不如Nearest过滤。事实上,两种过滤方法都会产生难看的锯齿效果。要想看一看效果是什么样的,请重新加载示例图像,这样它使用的就是Nearest(或者,点击刷新键,你就能看到它原来的状态);然后,按住Page Up键一会,来缩小图像。当立方体产生改变时,在某些点上,你会发现图像产生了“扭曲”,垂直的线条看起来时有时无。当你观察到这种现象时,稍微放大或缩小一下图像,观察一下扭曲的程度,接着按F键切换到Linear过滤,把立方体拉前推后,你会发现产生的扭曲效果和之前一样。再按一下F键,使用Mipmaps过滤,再次放大和缩小图像,你会发现,扭曲效果被消除了,至少是减少了。

当立方体离我们比较远时,比如在宽度和高度是Canvas画布的十分之一时,请让它在这个位置旋转然后切换纹理过滤方式。当使用Nearest过滤或者Linear过滤时,你会发现,在某些地方组成木质纹理的暗色线条十分清晰,然而在另外一些地方,这些线条缺消失了,图像看起来污渍斑斑的。这种情况在使用Nearest过滤时,十分严重,在使用Linear 过滤时也好不到哪儿去。只有在使用Mipmaps过滤时,才看的过去。

Nearest过滤和Linear过滤发生问题的原因是,当纹理被缩小为原来十分之一的尺寸时,纹理过滤将在原始图像的每十个像素中,使用一个来拼凑成缩小的纹理图像。实例中纹理是一个木质颗粒的图案,纹理的整体是浅棕色的,并且在垂直方向上有一些暗色的细线条。假设每个颗粒是10个像素大小,或者换句话说,水平方向上每十个像素就有一个暗棕色像素。当纹理图像被缩小为原图十分之一时,任何一个像素都会有十分之一的几率变成暗棕色,而十分之九的几率是光亮的。换句话说,只有十分之一的暗色的线条看起来会和原始尺寸的纹理图像一样清晰,其他的则都被隐藏了。这样就造成了看起来斑斑点点的效果,并且当纹理图像被放大缩小时,会产生扭曲。

我们需要做的是,在我们需要将纹理图像缩放为原来十分之一时候和场合,缩小后的图像中的每个像素的颜色,都根据一个10×10像素的像素块的平均值来计算。但是这种处理方式在真实使用时会耗费大量计算开销,因此,我们就有了Mipmap过滤。

Mipmaps过滤通过为纹理图像生成许多被称为mip level的子图像的方法解决了这个问题。这些图像分别是原图尺寸大小、四分之一大小、十六分之一大小……直到1×1像素大小。所有这些子图像的集合被称为mipmap。每一个mip level都是上一级大一点的mip level的平均值,这样,就很容易为当前缩放规格找出合适的图像版本:这一算法依赖于gl.TEXTURE_MIN_FILTER的值, 它所做的就是根据当前缩放规格,找到最合适的mip level,然后运用Linear过滤获得适合的像素。

这样就解释的很清楚了。另外,我们给第三个纹理加上了一行代码

137
        gl.generateMipmap(gl.TEXTURE_2D);

用来告诉WebGL为当前活动纹理生成对应的一系列MipMap层。

显然,对于Mipmaps我已经讲了非常多了,应当很清楚了,如果还有什么不理解的地方,请留下评论联系我。

让我们再次回到代码中。目前,我们已经看过了全局变量,并且了解了纹理是如何被加载和设置的。现在,我们来看一下全局变量和纹理在实际场景绘制中的应用。

drawScene函数位于示例代码的三分之一处,这里只有三处变化。第一处是我们在绘制立方体时,我们使用的是全局变量z,而不是一个固定的点:

362
        mvMatrix = okMat4Trans(0.0, 0.0, z);

第二个变化是我们移除了第五课的一行代码,我们不让立方体围绕Z轴转动了,而是只围绕X轴和Y轴转动:

364
365
        mvMatrix.rotX(OAK.SPACE_LOCAL, xRot, true);
        mvMatrix.rotY(OAK.SPACE_LOCAL, yRot, true);

最后,我们要开始绘制立方体了,我们需要指明我们使用的那三个纹理。

376
        gl.bindTexture(gl.TEXTURE_2D, crateTextures[filter]);

这些就是drawScene函数中的所有变化。另外在animate中也有一些小的变化,除了改变xRot和yRot的恒定速率,我们还加上了xSpeed 和ySpeed的相关变量:

392
393
            xRot += (xSpeed * elapsed) / 1000.0;
            yRot += (ySpeed * elapsed) / 1000.0;

这就是图形相关代码中发生的所有变化,但是不包括那些控制用户按键和更新全局变量的代码。下来我们要讲的就是这些。

出现的第一个相关的变化就在下面,在webGLStart函数中,我们加入了第418行和第419行这两行新的代码。

408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
    function webGLStart() {
        var canvas = document.getElementById("lesson06-canvas");
        initGL(canvas);
        initShaders();
        initBuffers();
        initTexture();

        gl.clearColor(0.0, 0.0, 0.0, 1.0);
        gl.enable(gl.DEPTH_TEST);

        document.onkeydown = handleKeyDown;
        document.onkeyup = handleKeyUp;

        tick();
    }

我们很明显可以看出,我们在这里告诉JavaScript,当一个按键被按下时,我们希望名为handleKeyDown的函数被调用,当按键被释放时,函数handleKeyUp被调用。

接着我们看一下这些函数。他们位于示例代码的中间位置,就在我们先前看过的全局变量的下面。他们长这样:

203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
    var currentlyPressedKeys = {};

    function handleKeyDown(event) {
        currentlyPressedKeys[event.keyCode] = true;

        if (String.fromCharCode(event.keyCode) == "F") {
            filter += 1;
            if (filter == 3) {
                filter = 0;
            }
        }
    }

    function handleKeyUp(event) {
        currentlyPressedKeys[event.keyCode] = false;
    }

在这里我们维护了一个字典,可能你也知道它叫做哈希表(hashtable)或关联数组(associative array)。这个字典用来保存按键代码——键盘上的按键在JavaScript中的数字标示符——能告诉我们按键是否正被用户按下。如果你并不熟悉JavaScript 是如何运作,你会发现,任何对象都能够作为一个单独的字典来使用; 我们用来初始化currentlyPressedKey的语法看起来像一个Python字典,实际上它仅仅是一个空的基础对象类型的实例。

除了这个之外,我们在处理按键被按下的事件中还增加了另外一些东西,这些东西是为当F键被按下时所准备的。在这段代码中,每次按下F键时,filter全局变量的值都会在0,1,2 这三个数字之间循环改变。

这里很值得花些时间讲一讲为什么我们用两种不同的方法处理不同的按键。在电脑游戏中,或者在其他类似的3D系统中,按键能以以下两种形式工作:

一种是立即做出反应。比如发射激光枪!按键后,按某种固定速率自动重复,比如每秒发射两次。

另一种是根据按键时间长短期发生作用。例如,按方向键向前走,直到松手之后,才会停下。

对于第二种按键方式来说,当你按住一个键时,你可能还会想按其他的键,这样你就能比如说向前跑、转弯,或者在移动中射击。这样的话,和通常处理文字时的按键解读方式不同,这是一种完全不同的按键解读方法。例如,你在一个文字处理器中按住A键,会出现一长串的A,但是当你按住A的同时时按了B,B会出现,但是一长串A也会停止。如果同样的事情发生在游戏中,你在跑步或者转弯时,就会受阻。这很显然是非常让人厌恶的。

所以,我们这里只需要将F键按照第一种按键方式处理。字典将在处理第二种按键方式的代码中使用,它会不断记录过程中同时按下的所有按键,而不只是最后一个被按下的键。

这个字典实际上会被各种不同的函数调用,比如handleKeys。在这之前,请先跳到代码末尾处,我们会看到tick函数也调用了它,就像drawScene和animate一样:

399
400
401
402
403
404
    function tick() {
        okRequestAnimationFrame(tick);
        handleKeys();
        drawScene();
        animate();
    }

代码是这个样子滴:

222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
    function handleKeys() {
        if (currentlyPressedKeys[33]) {
            // Page Up
            z -= 0.05;
        }
        if (currentlyPressedKeys[34]) {
            // Page Down
            z += 0.05;
        }
        if (currentlyPressedKeys[37]) {
            // Left cursor key
            ySpeed -= 1;
        }
        if (currentlyPressedKeys[39]) {
            // Right cursor key
            ySpeed += 1;
        }
        if (currentlyPressedKeys[38]) {
            // Up cursor key
            xSpeed -= 1;
        }
        if (currentlyPressedKeys[40]) {
            // Down cursor key
            xSpeed += 1;
        }
    }

这段代码虽然长,但是相对简单,它所做的就是检查是否按键被按下,并且在适当的时候更新全局变量。最重要的,当方向键“上”和“右”同时被按下,他会立即更新xSpeed和ySpeed,从而得到我们需要的效果。

好了,以上就是本节课的全部内容。在本节课中,你学到了各种不同的纹理过滤方式在处理缩放时的不同,并且还学会了如何在3D动画中读取用户的键盘输入。

如果你有任何的问题、评论或者修正,都请留下评论。在下节课中,我们将会开始加入光线。

2 对 “【转载】 WebGL教程 Lesson 6 键盘输入和纹理过滤”的想法;

Virginia进行回复 取消回复

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