WebGL教程12:球体、旋转矩阵和鼠标事件
来源:http://www.hiwebgl.com
HiWebGL译者声明:因为译者个人方便的原因,我们将原教程中的第三方图形库由glMatrix改为Oak3D实现,这不影响到Demo的最终效果和实现,也不影响到WebGL的讲解和学习。原教程正文中相应的代码和讲解也为做了相应修改!本教程由HiWebGL翻译整理,转载请注明出处!
关于Oak3D:Oak3D是一套简单易用、性能优越的WebGL Javascript Library。您可以在他们的主页找到更多信息。Oak3D主页:http://www.oak3d.com
欢迎来到WebGL教程的第11课。本节课是第一个不是基于NeHe的OpenGL教程改编的课时。这节课里,我们将会演示在平行光照下的一个球体,并为其贴上纹理贴图,观察者可以使用鼠标来旋转球体。
下面的视频就是我们这节课将会完成的最终效果。
一开始当纹理还没有完整载入的时候你会看到一个白色的球体,载入完成后你将看到月球,并有来自右上方的光照。用鼠标拖拽球体,球体将会转动,光照效果依然存在。如果你想要改变光线的参数,和第7课一样,可以使用canvas下方的文本输入框。
下面我们来看看它是怎么工作的……
惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。
另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。
有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;或者点击这里下载我们为您准备好的压缩包。
和往常一样,我们还是从代码的底部开始我们的学习,一步一步讲解那些发生变化的代码。在body标签之前的HTML代码,与第七课相比并没有发生变化,所以我们直接来看一下webGLStart函数。
394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 |
function webGLStart() { var canvas = document.getElementById("lesson11-canvas"); initGL(canvas); initShaders(); initBuffers(); initTexture(); gl.clearColor(0.0, 0.0, 0.0, 1.0); gl.enable(gl.DEPTH_TEST); canvas.onmousedown = handleMouseDown; document.onmouseup = handleMouseUp; document.onmousemove = handleMouseMove; tick(); } |
继续往上看代码,我们来到了tick函数,在本节课中它只是简单的安排下一帧调用drawScene函数,因为它已经不需要处理键盘输入了(因为本课中我们没有键盘输入),也不需要运动场景(因为场景中物体的运动只对用户输入发生反应,没有独立的动画场景)
下一个比较重要的变动位于drawScene函数中。开始,我们还是用样板化的代码清空了canvas并设置了透视,然后和第7课一样用相同的代码设置了光照。
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
function drawScene() { gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); pMatrix = okMat4Proj(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0); var lighting = document.getElementById("lighting").checked; gl.uniform1i(shaderProgram.useLightingUniform, lighting); if (lighting) { gl.uniform3f( shaderProgram.ambientColorUniform, parseFloat(document.getElementById("ambientR").value), parseFloat(document.getElementById("ambientG").value), parseFloat(document.getElementById("ambientB").value) ); 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()); gl.uniform3f( shaderProgram.directionalColorUniform, parseFloat(document.getElementById("directionalR").value), parseFloat(document.getElementById("directionalG").value), parseFloat(document.getElementById("directionalB").value) ); } |
366 |
mvMatrix = okMat4Trans(0.0, 0.0, -6.0); |
367 |
mvMatrix = okMat4Mul(mvMatrix, moonRotationMatrix); |
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 |
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, moonTexture); gl.uniform1i(shaderProgram.samplerUniform, 0); gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexPositionBuffer); gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, |
那么,我们是如何建立顶点位置、法线、纹理坐标和顶点索引,并给它们赋值,来绘制一个球体呢?秘密就在于下面这个函数中:initBuffers。
函数开始先定义了对象数组的全局变量,然后指定了经度带和纬度带的数量,以及球体的半径。如果你打算在你自己的WebGL页面中使用这些代码,你应当将经度带、纬度带和弧度都参数化,并且将数组对象储存在其他地方而不是全局变量中。这里我没有这么做只是为了演示起来方便简单,我可不想因此影响了你的良好的面对对象以及函数化的编程理念。
251 252 253 254 255 256 257 258 259 |
var moonVertexPositionBuffer; var moonVertexNormalBuffer; var moonVertexTextureCoordBuffer; var moonVertexIndexBuffer; function initBuffers() { var latitudeBands = 30; var longitudeBands = 30; var radius = 2; |
让我们先从这些专业术语的定义开始。纬线就是在一个球体上,告诉你距离南极或北极有多远的线。在球体表面上丈量南极到北极的距离,是一个常量。如果你按照纬线将一个球体从上到下依次切开,那么在顶部和底部你会得到镜片形状的切片,然后慢慢的在中间得到光盘形状的切片。如果你很难视觉化的想象出来,请参考在制作蔬菜沙拉时切西红柿。只不过切的时候要保证每个切片表面从顶部到底部的距离都是相同的,显然中间部分的切片的厚度要比两端的大。
而经线是另外一种线,它们将球体分割成弓形。如果你按照经线切开一个球体,那么切出来的部分好像切橙子一样。
现在,为了绘制球体,想象一下我们在球体上画满了经线和纬线。我们要做的就是计算出这些经线和纬线的交点,用这些交点作为顶点位置。这样我们就可以把由两条相邻的经线和纬线所组成的四边形分割成两个三角形,然后绘制它们。下边的图片应该很清楚地表达出我们的目的。
下一个问题是,我们如何才能计算出这些经线和纬线的交点呢?让我们假设球体的半径是1,然后在X轴和Y轴平面上垂直切开球体,让原点处于球体中心位置。显然,切片的形状是一个正圆,一条条纬线平行穿过此圆。在图中,你会发现全部一共有10个纬线带,而我们正在指向从上往下数第3个纬线带。连结坐标轴原点和纬线与圆的交点,设Y轴与该连接线的夹角为θ。那么利用简单的三角学知识,我们可以算出这条纬线与圆的交点的X坐标是sin(θ),Y坐标是cos(θ)。
下面,让我们来概括一下如何计算出每条纬线上相应的点。因为每两条相邻纬线之间的球面距离都是相等的,我们可以根据θ的值来计算出每条纬线。每个半圆的弧度是π,所以θ的取值应该是从0、π/10、2π/10、 3π/10……一直到10π/10。这样我们就可以确保我们用纬线平均的将球体分割开来。
在每个确定的纬线上的点,不管他们的经度如何,都有相同的Y坐标。根据我们上面用方程求出的纬线与圆的交点的Y坐标,我们可以推出,在这个用10条纬线平均分割且半径为1的球体上,第n条纬线的Y坐标是cos(nπ / 10)。
这样我们就解决了Y坐标的问题。那X坐标和Z坐标如何确定呢?我们可以看出来,在Z坐标为0、Y坐标为cos(nπ / 10)的位置,X坐标是sin(nπ / 10)。让我们换一种方式来切割球体,就像左边的那幅图,我们在第N条纬线上,水平将球体切开。我们可以看到圆的半径是sin(nπ / 10),让我们设其为k。如果我们用经线将这个圆平均分割一下,假设是10条经线,我们同样设X轴和经线与圆的交点之间的夹角为φ,又有整个圆的弧度为2π,那么φ的取值应该是0、2π/10、4π/10……我们再利用简单的三角学知识计算一下,可以得出X坐标为kcos(φ),Z坐标为ksin(φ)。
总结一下,对于一个半径为r的球体,有m个纬线带和n个经线带,我们把从0到π的区间平均分成m等份就可以得到θ的取值范围,把0到2π的区间平均分成n等份就可以得到φ的取值范围,从而计算出坐标x,y,z的值。
x = r sinθ cosφ
y = r cosθ
z = r sinθ sinφ
以上就是我们如何计算出顶点的过程。那现在看看我们还需要哪些值,包括法线和纹理坐标。好吧,其实计算法线是相当容易的。你只要记住,法线就是一个直勾勾指向表面外部的一个长度为1的向量。对于一个半径为1的球体来说,法线就是从球体中心指向到表面的向量,而这个值我们已经在计算顶点的过程中计算出来了!事实上,计算顶点位置和法线向量的顺序应当是,在按照上面的公式运算中,在与半径相乘之前,先把结果储存一下,作为法线向量,然后再与半径相乘得到顶点位置。
纹理坐标,其实更简单。我们希望纹理贴图的提供者,提供给我们的是一张矩形图片。多说一句,WebGL(不是Javascript)会被其他形状的贴图搞晕。这样,我们就可以放心的假定,这张纹理图片的顶部和底部拉伸肯定是遵循墨卡托投影法则(Mercator Projection)的。这样就是说,我们可以从左到右按照经线平均分割纹理图片得出坐标u,从上到下按照纬线平均分割纹理图片得到坐标v。
好了,这就是全部的工作原理。对于Javascript来说,理解并运算以上原理是如此难以置信得简单方便!我们只需要循环遍历所有的纬线切片,在循环内我们再遍历所有的经线切片,之后我们就可以计算出法线、纹理坐标和顶点位置。唯一需要注意的是,在循环结束的条件中,循环变量必须大于经线或纬线的数量。所以这里我们必须使用小于等于而不是小于。也就是说,比如有30条经线,在每条纬线上就会产生31个顶点。因为根据三角函数的循环,最后一个顶点和第一个顶点的位置其实是相同的,这样的一个重叠让我们把所有东西都连接到了一起。
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 |
var vertexPositionData = []; var normalData = []; var textureCoordData = []; for (var latNumber=0; latNumber <= latitudeBands; latNumber++) { var theta = latNumber * Math.PI / latitudeBands; var sinTheta = Math.sin(theta); var cosTheta = Math.cos(theta); for (var longNumber=0; longNumber <= longitudeBands; longNumber++) { var phi = longNumber * 2 * Math.PI / longitudeBands; var sinPhi = Math.sin(phi); var cosPhi = Math.cos(phi); var x = cosPhi * sinTheta; var y = cosTheta; var z = sinPhi * sinTheta; var u = 1 - (longNumber / longitudeBands); var v = 1 - (latNumber / latitudeBands); normalData.push(x); normalData.push(y); normalData.push(z); textureCoordData.push(u); textureCoordData.push(v); vertexPositionData.push(radius * x); vertexPositionData.push(radius * y); vertexPositionData.push(radius * z); } } |
291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
var indexData = []; for (var latNumber=0; latNumber < latitudeBands; latNumber++) { for (var longNumber=0; longNumber < longitudeBands; longNumber++) { var first = (latNumber * (longitudeBands + 1)) + longNumber; var second = first + longitudeBands + 1; indexData.push(first); indexData.push(second); indexData.push(first + 1); indexData.push(second); indexData.push(second + 1); indexData.push(first + 1); } } |
好了,以上就是本节课的难点(至少解释起来很难)。让我们继续看看其他变化的代码。
在initBuffers函数上方有3个用于处理鼠标事件的函数。我们必须仔细的考察一下它们。让我们先想想我们的目的是什么。我们想让观察者可以使用拖拽来旋转月球。一个幼稚的想法就是,我们建立三个变量用来表示X、Y、Z轴的旋转。当用户拖拽鼠标的时候我们可以相应的调整变量值。如果用户上下拖拽鼠标,我们就调整X轴的旋转变量;如果用户左右拖拽鼠标,我们就调整Y轴的旋转变量。这么做的问题在于,当你围绕不同的轴旋转物体的时候,你实际上做的一系列不同的旋转,而这一系列不同旋转的应用顺序很重要。比如说,用户先让月球围绕Y轴旋转了90°,然后又向下拖拽鼠标。这时,如果我们按照原来说好的再围绕X轴做旋转,观察者就会发现实际上月球正在围绕Z轴旋转。因为第一次的旋转,同时也旋转了轴线。这对于观察者来说会变得很奇怪。当观察者先把物体围绕X轴旋转10°,再围绕Y轴旋转23°,再怎样怎样时,这种问题变的更糟糕。我们可以耍下小聪明——“给出当前的旋转装袋,如果用户向下拖拽鼠标,那就同时改变所有三个旋转变量”。其实,一共更简单的处理方式是,用某种方法记录下观察者施加给月球的每一个旋转,然后在我们绘制的时候重新展示出来。表面上看,这种方法似乎需要耗费大量资源。但是不要忘了,我们已经找到一种完美的方法来保持追踪几何体的一系列变换,并且只用一个操作就可以应用这些变换,那就是——矩阵!
我们维护一个用来储存当前月球旋转状态的矩阵,叫做moonRotationMatrix。当用户拖拽鼠标的时候,我们会捕获到一系列的鼠标事件,每捕获到一个鼠标事件,我们都会根据用户拖拽鼠标的量,计算出绕着当前X轴和Y轴旋转多少度。我们用一个矩阵来表示这两个旋转,然后左乘moonRotationMatrix——之所以使用左乘,理由和我们上节课设置相机一样,我们需要做一个逆操作,旋转是基于eye space的,而不是模型空间。
有了以上的说明,下面的代码就变得清晰起来。
211 212 213 214 215 216 217 218 219 220 221 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 |
var mouseDown = false; var lastMouseX = null; var lastMouseY = null; var moonRotationMatrix = new okMat4(); function handleMouseDown(event) { mouseDown = true; lastMouseX = event.clientX; lastMouseY = event.clientY; } function handleMouseUp(event) { mouseDown = false; } function handleMouseMove(event) { if (!mouseDown) { return; } var newX = event.clientX; var newY = event.clientY; var deltaX = newX - lastMouseX var newRotationMatrix = new okMat4(); newRotationMatrix.rotY(OAK.SPACE_LOCAL, deltaX / 10, true); var deltaY = newY - lastMouseY; newRotationMatrix.rotX(OAK.SPACE_LOCAL, deltaY / 10, true); moonRotationMatrix = okMat4Mul(newRotationMatrix, moonRotationMatrix); lastMouseX = newX lastMouseY = newY; } |
好了!这节课结束了,你学会了如何使用一种简单但是有效的算法来绘制球体;如何捕捉鼠标事件,让用户可以通过拖拽来和你的3D物体交互;如何使用矩阵来表示场景中物体的当前旋转状态。
下一节课中,我们将会讲解一种新的光源类型:点光源,它是一种来自于场景内部某处并且向外辐射的光源,类似于一个光秃秃的灯泡。