查看: 4296|回复: 0
打印 上一主题 下一主题

[WEBGL] WebGL教程12:球体、旋转矩阵和鼠标事件 【转】

[复制链接]
.    

3797

主题

11

听众

5万

积分

首席设计师

Rank: 8Rank: 8

纳金币
32328
精华
41

活跃会员 优秀版主 荣誉管理 论坛元老

跳转到指定楼层
楼主
发表于 2012-8-27 16:39:09 |只看该作者 |倒序浏览
欢迎来到WebGL教程的第11课。本节课是第一个不是基于NeHe的OpenGL教程改编的课时。这节课里,我们将会演示在平行光照下的一个球体,并为其贴上纹理贴图,观察者可以使用鼠标来旋转球体。
    下面的视频就是我们这节课将会完成的最终效果。


    一开始当纹理还没有完整载入的时候你会看到一个白色的球体,载入完成后你将看到月球,并有来自右上方的光照。用鼠标拖拽球体,球体将会转动,光照效果依然存在。如果你想要改变光线的参数,和第7课一样,可以使用canvas下方的文本输入框。
    下面我们来看看它是怎么工作的……
    惯例声明:本系列的教程是针对那些已经具备相应编程知识但没有实际3D图形经验的人的;目标是让学习者创建并运行代码,并且明白代码其中的含义,从而可以快速地创建自己的3D Web页面。如果你还没有阅读前一课,请先阅读前一课的内容吧。因为本课中我只会讲解那些与前一课中不同的新知识。
    另外,我编写这套教程是因为我在独立学习WebGL,所以教程中可能(非常可能)会有错误,所以还请风险自担。尽管如此,我还是会不断的修正bug和改正其中的错误的,所以如果你发现了教程中的错误,请告诉我。
    有两种方法可以获得上面实例的代码:在实例的独立页面中选择“查看源代码”;或者点击这里下载我们为您准备好的压缩包。
    和往常一样,我们还是从代码的底部开始我们的学习,一步一步讲解那些发生变化的代码。在body标签之前的HTML代码,与第七课相比并没有发生变化,所以我们直接来看一下webGLStart函数。
394     function webGLStart() {

395        var canvas = document.getElementById("lesson11-canvas");

396        initGL(canvas);

397        initShaders();

398        initBuffers();

399        initTexture();

400

401        gl.clearColor(0.0, 0.0, 0.0, 1.0);

402        gl.enable(gl.DEPTH_TEST);

403

404        canvas.onmousedown = handleMouseDown;

405        document.onmouseup = handleMouseUp;

406        document.onmousemove = handleMouseMove;

407

408        tick();

409    }



    这三行新增加的代码允许我们探测鼠标事件,当用户拖拽球体的时候可以相应的旋转它。很明显,我们只在3D canvas内部读取MouseDown事件(如果你在页面的其他部分进行了拖拽,将不会对球体产生影响,比如说点击文本框)。不那么明显的是,我们却在整个页面而不仅仅是canvas中,监听MouseUp和MouseMove事件;这样我们就可以完整地读取用户拖拽,即使鼠标被释放在或被移动到canvas之外,只要拖拽行为发生在canvas里面就可以了。这种解决方法让我们避免了成为那些愚蠢的交互页面中的一员——当你想要旋转物体的时候,在场景内按下鼠标,然后由于拖拽移动鼠标的缘故,在场景外释放了鼠标,但是你却发现当你把鼠标移动回场景内的时候,MouseUp事件却没有生效,笨蛋电脑依然认为你还是在拖拽当中,逼着你在场景内的某个位置单击鼠标才行。
    继续往上看代码,我们来到了tick函数,在本节课中它只是简单的安排下一帧调用drawScene函数,因为它已经不需要处理键盘输入了(因为本课中我们没有键盘输入),也不需要运动场景(因为场景中物体的运动只对用户输入发生反应,没有独立的动画场景)
    下一个比较重要的变动位于drawScene函数中。开始,我们还是用样板化的代码清空了canvas并设置了***,然后和第7课一样用相同的代码设置了光照。
332     function drawScene() {

333        gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);

334        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

335

336        pMatrix = okMat4Proj(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0);

337

338        var lighting = document.getElementById("lighting").checked;

339        gl.uniform1i(shaderProgram.useLightingUniform, lighting);

340        if (lighting) {

341            gl.uniform3f(

342                shaderProgram.ambientColorUniform,

343                parseFloat(document.getElementById("ambientR").value),

344                parseFloat(document.getElementById("ambientG").value),

345                parseFloat(document.getElementById("ambientB").value)

346            );

347

348            var lightingDirection = new okVec3(

349                parseFloat(document.getElementById("lightDirectionX").value),

350                parseFloat(document.getElementById("lightDirectionY").value),

351                parseFloat(document.getElementById("lightDirectionZ").value)

352            );

353

354            var adjustedLD = lightingDirection.normalize(false);

355            adjustedLD = okVec3MulVal(adjustedLD, -1.0);

356            gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD.toArray());

357

358            gl.uniform3f(

359                shaderProgram.directionalColorUniform,

360                parseFloat(document.getElementById("directionalR").value),

361                parseFloat(document.getElementById("directionalG").value),

362                parseFloat(document.getElementById("directionalB").value)

363            );

364        }



    然后,我们移动到正确的位置,开始绘制月球。
366         mvMatrix = okMat4Trans(0.0, 0.0, -6.0);



    下面,出现了一行比较奇怪的代码。我现在先不详细解释,大概说一下就是我们把月球的当前旋转状态储存在一个矩阵之中,这个矩阵从单位矩阵开始(表示我们没有做任何旋转),然后当用户进行鼠标操作时,矩阵也会发生对应于这个操作的相应变化。所以,在我们绘制月球之前,需要将旋转矩阵应用于当前的模型视图矩阵,我们使用了okMat4Mul函数。
367         mvMatrix = okMat4Mul(mvMatrix, moonRotationMatrix);



    完成之后,剩下的工作就是绘制月球了。这些代码都相当的标准——我们设置了纹理,然后告诉WebGL用建立好的数组对象来绘制一串三角形,这些代码我们在前几课用过很多次了。
         gl.activeTexture(gl.TEXTURE0);

369        gl.bindTexture(gl.TEXTURE_2D, moonTexture);

370        gl.uniform1i(shaderProgram.samplerUniform, 0);

371

372        gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexPositionBuffer);

373        gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute,

374 moonVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

375

376       gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexTextureCoordBuffer);

377        gl.vertexAttribPointer(shaderProgram.textureCoordAttribute,

378 moonVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

379

380       gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexNormalBuffer);

381        gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute,

382 moonVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

383

384        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, moonVertexIndexBuffer);

385        setMatrixUniforms();

        gl.drawElements(gl.TRIANGLES, moonVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0);

    }


    那么,我们是如何建立顶点位置、法线、纹理坐标和顶点索引,并给它们赋值,来绘制一个球体呢?秘密就在于下面这个函数中:initBuffers。
    函数开始先定义了对象数组的全局变量,然后指定了经度带和纬度带的数量,以及球体的半径。如果你打算在你自己的WebGL页面中使用这些代码,你应当将经度带、纬度带和弧度都参数化,并且将数组对象储存在其他地方而不是全局变量中。这里我没有这么做只是为了演示起来方便简单,我可不想因此影响了你的良好的面对对象以及函数化的编程理念。
251     var moonVertexPositionBuffer;

252    var moonVertexNormalBuffer;

253    var moonVertexTextureCoordBuffer;

254    var moonVertexIndexBuffer;

255

256    function initBuffers() {

257        var latitudeBands = 30;

258        var longitudeBands = 30;

259        var radius = 2;



    那么,究竟什么是经度带和纬度带呢?为了绘制一系列三角形来逼近球体,我们必须将球体分割开来。有很多聪明的技巧来实现这种分割,我们将使用其中最简单的方法,仅仅需要高中几何知识。这是因为(a)它的效果相当完美(b)我懂,并且理解起来不会让人头痛。这种方法是基于一个Khronos网站上的Demo的,原本是由WebKit团队开发的。它的原理如下:
    让我们先从这些专业术语的定义开始。纬线就是在一个球体上,告诉你距离南极或北极有多远的线。在球体表面上丈量南极到北极的距离,是一个常量。如果你按照纬线将一个球体从上到下依次切开,那么在顶部和底部你会得到镜片形状的切片,然后慢慢的在中间得到光盘形状的切片。如果你很难视觉化的想象出来,请参考在制作蔬菜沙拉时切西红柿。只不过切的时候要保证每个切片表面从顶部到底部的距离都是相同的,显然中间部分的切片的厚度要比两端的大。



    而经线是另外一种线,它们将球体分割成弓形。如果你按照经线切开一个球体,那么切出来的部分好像切橙子一样。



    现在,为了绘制球体,想象一下我们在球体上画满了经线和纬线。我们要做的就是计算出这些经线和纬线的交点,用这些交点作为顶点位置。这样我们就可以把由两条相邻的经线和纬线所组成的四边形分割成两个三角形,然后绘制它们。下边的图片应该很清楚地表达出我们的目的。


    下一个问题是,我们如何才能计算出这些经线和纬线的交点呢?让我们假设球体的半径是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         var vertexPositionData = [];

262        var normalData = [];

263        var textureCoordData = [];

264        for (var latNumber=0; latNumber <= latitudeBands; latNumber++) {

265            var theta = latNumber * Math.PI / latitudeBands;

266            var sinTheta = Math.sin(theta);

267            var cosTheta = Math.cos(theta);

268

269            for (var longNumber=0; longNumber <= longitudeBands; longNumber++) {

270                var phi = longNumber * 2 * Math.PI / longitudeBands;

271                var sinPhi = Math.sin(phi);

272                var cosPhi = Math.cos(phi);

273

274                var x = cosPhi * sinTheta;

275                var y = cosTheta;

276                var z = sinPhi * sinTheta;

277                var u = 1 - (longNumber / longitudeBands);

278                var v = 1 - (latNumber / latitudeBands);

279

280                normalData.push(x);

281                normalData.push(y);

282                normalData.push(z);

283                textureCoordData.push(u);

284                textureCoordData.push(v);

285                vertexPositionData.push(radius * x);

286                vertexPositionData.push(radius * y);

287                vertexPositionData.push(radius * z);

288            }

289        }



    现在我们已经处理完顶点了,还需要把它们缝合到一起。我们生成一个顶点索引列表,其中包括了上面六个值的序列,将每个四边形分成一对三角形。下面是代码:
291         var indexData = [];

292        for (var latNumber=0; latNumber < latitudeBands; latNumber++) {

293            for (var longNumber=0; longNumber < longitudeBands; longNumber++) {

294                var first = (latNumber * (longitudeBands + 1)) + longNumber;

295                var second = first + longitudeBands + 1;

296                indexData.push(first);

297                indexData.push(second);

298                indexData.push(first + 1);

299

300                indexData.push(second);

301                indexData.push(second + 1);

302                indexData.push(first + 1);

303            }

304        }



    这些代码实在是太容易理解了。我们通过循环遍历了所有顶点,对于每个顶点,我们将其索引值储存在first变量中,然后向前数longitudeBands + 1个顶点,找到和它配对的下一个纬线带,储存在second变量中(之所以+1是因为我们额外增加的那一个顶点会重叠)。这样我们就生成了两个三角形,如图所示。



    好了,以上就是本节课的难点(至少解释起来很难)。让我们继续看看其他变化的代码。
    在initBuffers函数上方有3个用于处理鼠标事件的函数。我们必须仔细的考察一下它们。让我们先想想我们的目的是什么。我们想让观察者可以使用拖拽来旋转月球。一个幼稚的想法就是,我们建立三个变量用来表示X、Y、Z轴的旋转。当用户拖拽鼠标的时候我们可以相应的调整变量值。如果用户上下拖拽鼠标,我们就调整X轴的旋转变量;如果用户左右拖拽鼠标,我们就调整Y轴的旋转变量。这么做的问题在于,当你围绕不同的轴旋转物体的时候,你实际上做的一系列不同的旋转,而这一系列不同旋转的应用顺序很重要。比如说,用户先让月球围绕Y轴旋转了90&deg;,然后又向下拖拽鼠标。这时,如果我们按照原来说好的再围绕X轴做旋转,观察者就会发现实际上月球正在围绕Z轴旋转。因为第一次的旋转,同时也旋转了轴线。这对于观察者来说会变得很奇怪。当观察者先把物体围绕X轴旋转10&deg;,再围绕Y轴旋转23&deg;,再怎样怎样时,这种问题变的更糟糕。我们可以耍下小聪明——“给出当前的旋转装袋,如果用户向下拖拽鼠标,那就同时改变所有三个旋转变量”。其实,一共更简单的处理方式是,用某种方法记录下观察者施加给月球的每一个旋转,然后在我们绘制的时候重新展示出来。表面上看,这种方法似乎需要耗费大量资源。但是不要忘了,我们已经找到一种完美的方法来保持追踪几何体的一系列变换,并且只用一个操作就可以应用这些变换,那就是——矩阵!
    我们维护一个用来储存当前月球旋转状态的矩阵,叫做moonRotationMatrix。当用户拖拽鼠标的时候,我们会捕获到一系列的鼠标事件,每捕获到一个鼠标事件,我们都会根据用户拖拽鼠标的量,计算出绕着当前X轴和Y轴旋转多少度。我们用一个矩阵来表示这两个旋转,然后左乘moonRotationMatrix——之所以使用左乘,理由和我们上节课设置相机一样,我们需要做一个逆操作,旋转是基于eye space的,而不是模型空间。
    有了以上的说明,下面的代码就变得清晰起来。
211     var mouseDown = false;

212    var lastMouseX = null;

213    var lastMouseY = null;

214

215    var moonRotationMatrix = new okMat4();

216

217    function handleMouseDown(event) {

218        mouseDown = ***e;

219        lastMouseX = event.clientX;

220        lastMouseY = event.clientY;

221    }

222

223

224    function handleMouseUp(event) {

225        mouseDown = false;

226    }

227

228

229    function handleMouseMove(event) {

230        if (!mouseDown) {

231            return;

232        }

233        var newX = event.clientX;

234        var newY = event.clientY;

235

236        var deltaX = newX - lastMouseX

237        var newRotationMatrix = new okMat4();

238        newRotationMatrix.rotY(OAK.SPACE_LOCAL, deltaX / 10, ***e);

239

240        var deltaY = newY - lastMouseY;

241        newRotationMatrix.rotX(OAK.SPACE_LOCAL, deltaY / 10, ***e);

242

243        moonRotationMatrix = okMat4Mul(newRotationMatrix, moonRotationMatrix);

244

245        lastMouseX = newX

246        lastMouseY = newY;

247    }



    以上就是本节课的另一个重点。代码中剩下的变化都很简单,比如载入新的纹理和新的变量名称。
    好了!这节课结束了,你学会了如何使用一种简单但是有效的算法来绘制球体;如何捕捉鼠标事件,让用户可以通过拖拽来和你的3D物体交互;如何使用矩阵来表示场景中物体的当前旋转状态。
    下一节课中,我们将会讲解一种新的光源类型:点光源,它是一种来自于场景内部某处并且向外辐射的光源,类似于一个光秃秃的灯泡

分享到: QQ好友和群QQ好友和群 腾讯微博腾讯微博 腾讯朋友腾讯朋友 微信微信
转播转播0 分享淘帖0 收藏收藏0 支持支持0 反对反对0
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

手机版|纳金网 ( 闽ICP备2021016425号-2/3

GMT+8, 2025-7-23 06:11 , Processed in 0.085659 second(s), 33 queries .

Powered by Discuz!-创意设计 X2.5

© 2008-2019 Narkii Inc.

回顶部