WebGL教程11:载入世界,以及相机简介
来源:http://www.hiwebgl.com
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的小游戏!
下面的视频就是我们这节课将会完成的最终效果。
你会发现你自己身处一个房间之中,墙上贴满了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"><<返回 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"><<返回 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> |
首先第一个小改动位于已经成为我们的标准的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(); } |
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(); } |
接着,让我们来看看handleLoadedWorld函数,它就在loadWorld函数上面。
233 234 235 236 |
var worldVertexPositionBuffer = null; var worldVertexTextureCoordBuffer = null; function handleLoadedWorld(data) { |
怎么样,是不是觉得这个文件格式完美无瑕?好吧,实际上它漏洞百出!它忽略了很多我们在真实场景应用中需要的信息,比如法线、不同物体的不同纹理。在真实的应用中,我们应该使用另外一种格式,或者直接使用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; } } |
下面的代码现在看起来应该非常熟悉
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; |
269 270 |
document.getElementById("loadingtext").textContent = ""; } |
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 |
好了,让我们继续看看下一段比较有趣代码——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(); |
一个简单的例子也许有助于你理解。让我们设想一下一个简单的场景,其中有一个立方体,在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, |
这些工作都是由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; } } |
和前面的课程一样,这些改变的速率都会在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; |
让我们看看这部分的代码,同时代码中也调整了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 } |
然后,我们需要根据yaw和pitch各自相应的改变速率调整它们的值,这个运算在我们站在原地不移动的时候也会进行。
337 338 |
yaw += yawRate * elapsed; pitch += pitchRate * elapsed; |
340 341 342 |
} lastTime = timeNow; } |
下节课中我们将会演示如何显示一个球体,然后使用鼠标事件旋转它,然后还会为你讲解如何使用旋转矩阵去避免一个讨厌的问题——万向节死锁(gimbal-lock)。