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

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

欢迎来到WebGL教程的第10课,这节课是基于NeHe的OpenGL教程的第10节改编的。在这节课中,我们将会从一个文件中载入3D场景(这样我们就可以通过切换文件来轻松扩展Demo),然后会写一些简单的代码让我们可以在场景中移动,用Doom自带的WAD文件格式,实现一个类似于Doom的小游戏!

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

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

你会发现你自己身处一个房间之中,墙上贴满了Lionel Brits的照片,他撰写了原始的OpenGL教程,也就是我们一直基于其改编的Nehe的OpenGL教程(/致敬)。使用方向键或WASD键,你可以在房间里来回走动,还可以走出房间;使用Page Up和Page Down键,你可以抬头低头。特需要注意的是,为了增强真实性,你的视角也会在移动的时候一上一下,类似于慢跑时的点头。

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

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

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

要获得上面实例的代码,请点击这里下载我们为您准备好的压缩包。这里不建议通过查看源代码的方式获得实例,因为下载下来的world.txt的编码可能会发生变化,导致场景不可用。另外,因为要载入本地资源,所以请使用Chrome浏览器的读者为Chrome增加如下命令行:“–allow-file-access-from-files”;请使用Firefox的读者打开about:config配置页面,找到“security.fileuri.strict_origin_policy”项,并将其设置为“false”。除此之外,你也可以安装一个web服务器,然后通过服务器加载电脑上的资源。

和前面几课一样,最简单的讲解方式就是从代码底部开始。所以让我们先从body标签里的HTML代码开始吧!自从第一课开始,这部分代码第一次有了些有意思的东西!

387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
<body onload="webGLStart();">
<br>
WebGL中文教程 - 由HiWebGL整理翻译 - 感谢<a href="http://www.oak3d.com">Oak3D</a>提供图形库支持!<br>
<br><a href="http://www.hiwebgl.com/?p=327">&lt;&lt;返回 Lesson 10</a><br>

    <canvas id="lesson10-canvas" style="border: none;" width="500" height="500"></canvas>

    <div id="loadingtext">Loading world...</div>

    <br/>
    使用方向键或WASD移动,使用 <code>Page Up</code>键和<code>Page Down</code> 键来上看下看。

    <br/>
<br><a href="http://www.hiwebgl.com/?p=327">&lt;&lt;返回 Lesson 10</a><br>

</body>

我们使用了DIV标签来充当占位符,在载入世界时显示Loading。如果客户端与服务器端的连接缓慢,客户端就会看到正在载入的提示信息。当然,这条信息必须要显示在canvas之上而不是之下,这是由一段CSS代码来控制的,它就位于head标签的尾部。

372
373
374
375
376
377
378
379
380
<style type="text/css">
    #loadingtext {
        position:absolute;
        top:250px;
        left:150px;
        font-size:2em;
        color: white;
    }
</style>

好了,这就是HTML代码的部分。下面我们来看看Javascript部分。

首先第一个小改动位于已经成为我们的标准的webGLStart函数中,除了通常的部署初始化的代码之外,还调用了一个新的函数用于从服务器载入世界。

354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
    function webGLStart() {
        var canvas = document.getElementById("lesson10-canvas");
        initGL(canvas);
        initShaders();
        initTexture();
        loadWorld();

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

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

        tick();
    }

让我们往上走走,loadWorld函数就在drawScene函数的上面,大概在整个代码的四分之三处。它长的是这个样子滴:

273
274
275
276
277
278
279
280
281
282
    function loadWorld() {
        var request = new XMLHttpRequest();
        request.open("GET", "world.txt");
        request.onreadystatechange = function () {
            if (request.readyState == 4) {
                handleLoadedWorld(request.responseText);
            }
        }
        request.send();
    }

代码的样式看起来非常眼熟,很像之前我们用来载入纹理的那段代码。我们建立了一个XMLHttpRequest对象,让它来负责载入,然后告诉他使用HTTP GET请求来获取与当前网页文件相同服务器、相同目录下的名为world.txt的文件。我们还指定了一个回调函数,在不同的载入阶段,回调函数会被更新。当XMLHttpRequest报告说它手下的readyState已经等于4啦,也就是文件已经被完整的载入了,回调函数就会调用handleLoadedWorld。最后,我们告诉XMLHttpRequest使用send方法来正式开始获取文件。

接着,让我们来看看handleLoadedWorld函数,它就在loadWorld函数上面。

233
234
235
236
    var worldVertexPositionBuffer = null;
    var worldVertexTextureCoordBuffer = null;

    function handleLoadedWorld(data) {

这个函数的工作就是解析载入文件的内容,并且使用这些内容来建立两个我们之前课程中已经不厌其烦的对象数组——顶点位置数组和纹理坐标数组。载入文件中的内容是被当做字符串来传入的,参数的名字是data。开始的一小段代码解析了这些文件内容。在这节课的示例中我们使用的文件格式非常简单,它包含一个三角形的列表,每一个都由3个顶点来指定;文件中每行都是一个顶点,每个顶点包含5个值:位置坐标X、位置坐标Y、位置坐标Z、纹理坐标S和纹理坐标T。文件中还包括注释(以“//”起头的那些行)和空白行,这些在处理的时候都会被忽略。文件在第一行还给出了三角形的总数,尽管我们实际上并不会用到这个数字。

怎么样,是不是觉得这个文件格式完美无瑕?好吧,实际上它漏洞百出!它忽略了很多我们在真实场景应用中需要的信息,比如法线、不同物体的不同纹理。在真实的应用中,我们应该使用另外一种格式,或者直接使用JSON。我依然在教程中使用这种简单的文件格式,是因为原本NeHe的OpenGL教程中使用了它,并且它真的很容易理解,处理起来也很简单。好吧,说了这么多,因为真实应用中不会使用这种格式,所以我不会详细讲解下面的解析代码的细节。

237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
        var lines = data.split("\n");
        var vertexCount = 0;
        var vertexPositions = [];
        var vertexTextureCoords = [];
        for (var i in lines) {
            var vals = lines[i].replace(/^\s+/, "").split(/\s+/);
            if (vals.length == 5 && vals[0] != "//") {
                // It is a line describing a vertex; get X, Y and Z first
                vertexPositions.push(parseFloat(vals[0]));
                vertexPositions.push(parseFloat(vals[1]));
                vertexPositions.push(parseFloat(vals[2]));

                // And then the texture coords
                vertexTextureCoords.push(parseFloat(vals[3]));
                vertexTextureCoords.push(parseFloat(vals[4]));

                vertexCount += 1;
            }
        }

最后说一句,以上代码所做的实际上是将读取由空格分隔的5个属性值,然后用它们组成顶点位置数组和纹理坐标数组;另外还在vertexCount变量中记录了顶点的数量。

下面的代码现在看起来应该非常熟悉

257
258
259
260
261
262
263
264
265
266
267
        worldVertexPositionBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexPositionBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexPositions), gl.STATIC_DRAW);
        worldVertexPositionBuffer.itemSize = 3;
        worldVertexPositionBuffer.numItems = vertexCount;

        worldVertexTextureCoordBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexTextureCoordBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexTextureCoords), gl.STATIC_DRAW);
        worldVertexTextureCoordBuffer.itemSize = 2;
        worldVertexTextureCoordBuffer.numItems = vertexCount;

我们建立了从文件中载入的顶点位置数组和纹理坐标数组。最终,当这些准备工作都已经就绪时,我们清空了原本显示“Loading World…”的DIV标签中的内容。

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

以上就是从文件中载入世界所需要的全部代码。在我们继续研究其他代码之前,让我们先休息一下,来看看这个world.txt中的一些有趣的东西。首先是前三个顶点,用于描述场景中的第一个三角形,它看起来是这样的:

4
5
6
7
// Floor 1
-3.0  0.0 -3.0 0.0 6.0
-3.0  0.0  3.0 0.0 0.0
 3.0  0.0  3.0 6.0 0.0

你还记得,这5个值分别是X、Y、Z、S、T,其中S和T是纹理坐标。你可以看到纹理坐标是介于0到6之间的。但是我之前说过纹理坐标的范围是位于0到1的区间中的。这是怎么回事呢?答案就是,当你求纹理中的一个点时,S和T的坐标值将自动对1取模,所以5.5实际上与纹理中0.5的那个点是相同的点。这就意味着,实际上纹理会自动平铺,不断重复,直到填满整个三角形。这显然十分有用,尤其是当你想用一个很小的纹理填充一个很大的物体时——比如用一块砖的纹理覆盖整个一面墙。

好了,让我们继续看看下一段比较有趣代码——drawScene函数。首先,函数检查了一下当我们完成载入世界之后,相应的对象数组是否被正确建立;如果没有,将会有一个应急处理:

286
287
288
289
290
291
292
    function drawScene() {
        gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

        if (worldVertexTextureCoordBuffer == null || worldVertexPositionBuffer == null) {
            return;
        }

如果对象数组确认无误,下一步就是我们通常所做的,建立投影矩阵和模型视图矩阵。

294
295
296
        pMatrix = okMat4Proj(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0);

        mvMatrix = new okMat4();

接下来我们要着手处理相机(camera)了,也就是允许视角按照我们想要的方式来移动穿越场景。首先你要记住的事,和其他很多特性一样,WebGL并不直接支持相机,但是要模拟一个也不是什么困难的事情。如果我们有了相机,在这个简单的示例中,我们想要的是,把它放置于一个指定的X、Y、Z坐标上,并且可以围绕X轴做倾斜用于观察上下(pitch),可以围绕Y轴做转动用于左转向右转向(yaw)。因为我们不能改变相机的位置——实际上它永远都处于坐标(0,0,0)并且看向Z轴的负半轴,所以我们想要做的是告诉WebGL去调整我们绘制的场景,这个用X、Y、Z坐标指定的空间我们称之为world space;将其调整到一个新的基于相机的位置和旋转的参照系中去,我们称之为eye space。

一个简单的例子也许有助于你理解。让我们设想一下一个简单的场景,其中有一个立方体,在world space中它的中心坐标是(1,2,3)。我们想要模拟一个处于(0,0,7)坐标、既没有pitch也没有yaw、看向Z轴负半轴的相机。为了实现这个效果,我们把立方体中心坐标从world space的坐标转换为eye space的坐标。这么理解或许会简单一些,也许也没简单多少。

我们大概比较清楚的就是又要用到矩阵了,我们会维护一个称之为相机矩阵(camera matrix)的东西,用来表示相机的位置和旋转。但是在这节课的示例中,我们简单一些,我们只要使用既有的模型视图矩阵就可以了。

从上面的实例中可以明显的推理出,我们模拟相机的方式就是移动场景,相机不动,而让场景朝我们想要移动方向的相反方向“退回”,然后再根据通常使用的相关坐标系,绘制出场景的方法。假设我们把自己想象成相机,我们想要移动到某个指定位置,然后做指定的旋转。“退回”的意思就是,我们做一个相反的旋转,然后再做一个相反的移动。

从数学的角度讲,比如我们模拟一个位置在(x,y,z)的相机,然后做ψ角度yaw旋转,再做一个θ角度的pitch旋转;那我们就需要先做一个围绕X轴的-θ角度旋转,再做一个围绕Y轴的-ψ角度旋转,然后移动到(-x,-y,-z)。完成后,我们把所有要绘制的东西的状态用world space中的坐标都储存到模型视图矩阵中,最后在顶点着色器中通过矩阵的乘法运算,会神奇般地将其转换为eye space中的坐标。

当然,还有其他的方法来放置相机,我们将会在以后的课程中讨论。现在,我们先来看看上述做法的代码:

297
298
299
        mvMatrix.rotX(OAK.SPACE_LOCAL, -pitch, true);
        mvMatrix.rotY(OAK.SPACE_LOCAL, -yaw, true);
        mvMatrix.translate(OAK.SPACE_LOCAL, -xPos, -yPos, -zPos, true);

完成后,我们要做的就是根据之前我们载入的对象数组中所描述的,绘制出整个场景。下面的代码看起来和之前几课非常相似。

301
302
303
304
305
306
307
308
309
310
311
312
313
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, mudTexture);
        gl.uniform1i(shaderProgram.samplerUniform, 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexTextureCoordBuffer);
        gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, worldVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexPositionBuffer);
        gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, worldVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

        setMatrixUniforms();
        gl.drawArrays(gl.TRIANGLES, 0, worldVertexPositionBuffer.numItems);
    }

好了,到此我们就解决了本课中新增加的那一批代码。最后我们来看看控制移动,包括“慢跑”是上下点头的那一部分代码。和之前课程中的一样,这节课页面中的键盘动作也是设计成给所有人一个相同的运动速率,不管他们的电脑性能如何。电脑好的人仅仅是能获得更好的fps帧率,而不是更快的移动速度!

这些工作都是由handleKeys函数来完成的,我们根据用户当前正按下的键来计算出一个速率——也就是位置改变的速率——一个pitch改变的速率和一个yaw改变的速率。如果没有键被按下,那么这些值都会被设置为0;如果相应的键被按下,那么它们会被设为一个固定的值,单位是每毫秒。你可以在页面的三分之二处找到这些代码。

187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
    var pitch = 0;
    var pitchRate = 0;

    var yaw = 0;
    var yawRate = 0;

    var xPos = 0;
    var yPos = 0.4;
    var zPos = 0;

    var speed = 0;

    function handleKeys() {
        if (currentlyPressedKeys[33]) {
            // Page Up
            pitchRate = 0.1;
        } else if (currentlyPressedKeys[34]) {
            // Page Down
            pitchRate = -0.1;
        } else {
            pitchRate = 0;
        }

        if (currentlyPressedKeys[37] || currentlyPressedKeys[65]) {
            // Left cursor key or A
            yawRate = 0.1;
        } else if (currentlyPressedKeys[39] || currentlyPressedKeys[68]) {
            // Right cursor key or D
            yawRate = -0.1;
        } else {
            yawRate = 0;
        }

        if (currentlyPressedKeys[38] || currentlyPressedKeys[87]) {
            // Up cursor key or W
            speed = 0.003;
        } else if (currentlyPressedKeys[40] || currentlyPressedKeys[83]) {
            // Down cursor key
            speed = -0.003;
        } else {
            speed = 0;
        }

    }

就拿上面的代码为例,如果左方向键被按下,那么yawRate就被设置为0.1°/ms,也就是100°/s。换句话说,如果我们向左旋转,旋转一周的时间需要3.6秒。

和前面的课程一样,这些改变的速率都会在animate函数中被调用,以设置xPos和zPos。同时, 在animate函数中也会设置yaw和pitch.yPos,但是逻辑上有些不同。让我们看一下代码,它就在drawScene函数的下面,接近底部,下面是开头的几行:

320
321
322
323
324
325
326
327
    var lastTime = 0;
    // Used to make us "jog" up and down as we move forward.
    var joggingAngle = 0;

    function animate() {
        var timeNow = new Date().getTime();
        if (lastTime != 0) {
            var elapsed = timeNow - lastTime;

其中大部分都是正常的代码,用于计算出距离上次animate函数被调用的毫秒数。joggingAngle是个很有意思的变量。我们用来获得点头效果的方法是,在移动是,让Y坐标跟随头部高度中心位置的正弦波移动。joggingAngle这个角度变量,就是用于传递给正弦函数计算当前位置的。

让我们看看这部分的代码,同时代码中也调整了x和z来实现移动:

329
330
331
332
333
334
335
            if (speed != 0) {
                xPos -= Math.sin(degToRad(yaw)) * speed * elapsed;
                zPos -= Math.cos(degToRad(yaw)) * speed * elapsed;

                joggingAngle += elapsed * 0.6; // 0.6 "fiddle factor" - makes it feel more realistic :-)
                yPos = Math.sin(degToRad(joggingAngle)) / 20 + 0.4
            }

显然,位置的改变和所谓的慢跑时的点头效果都应该发生在我们移动的时候,所以如果speed为非零值,就会根据三角函数和当前速度来调整xPos和zPos(因为Javascript中的三角函数使用的是弧度制,所以我们借助degToRad函数将角度值转换为弧度值)。接下来,joggingAngle继续参与运算,计算出当前的yPos。所有我们使用到的数字都乘以距离上次animate函数被调用的毫秒数,尤其是对于speed,它已经被设置为以毫秒为单位了,这非常完美。

然后,我们需要根据yaw和pitch各自相应的改变速率调整它们的值,这个运算在我们站在原地不移动的时候也会进行。

337
338
            yaw += yawRate * elapsed;
            pitch += pitchRate * elapsed;

最后,我们需要记录当前时间,以便我们可以在下次animate函数被调用时计算出中间的时间间隔。

340
341
342
        }
        lastTime = timeNow;
    }

好了,本节课到此为止!你学会了一种简单的从txt文件中载入场景的方法,和一种简单的应用相机的方法。

下节课中我们将会演示如何显示一个球体,然后使用鼠标事件旋转它,然后还会为你讲解如何使用旋转矩阵去避免一个讨厌的问题——万向节死锁(gimbal-lock)。

(其实下节课根本没有讲到万向节死锁,大家千万不要上当!大概是作者写着写着就忘了吧……如果有需要,请给我们留言,我们会专门专业一篇文章来讲解所谓的“万向节死锁”的!)

发表评论

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