WebGL或OpenGL關於模型檢視投影變換的設定技巧
1. 具體例項
看了不少的關於WebGL/OpenGL的資料,筆者發現這些資料在講解圖形變換的時候都講了很多的原理,然後舉出一個特別簡單的例項(座標是1.0,0.5的那種)來講解。確實一看就懂,但用到實際的場景之中就一臉懵逼了(比如地形的三維座標都是很大的數字)。所以筆者這裡結合一個具體的例項,總結下WebGL/OpenGL中,關於模型變換、檢視變換、投影變換的設定技巧。
繪製任何複雜的場景之前,都可以先繪製出其包圍盒,能應用於包圍盒的圖形變換,基本上就能用於該場景了,因此,筆者這裡繪製一幅地形的包圍盒。它的最大最小範圍為:
//包圍盒範圍 var minX = 399589.072; var maxX = 400469.072; var minY = 3995118.062; var maxY = 3997558.062; var minZ = 732; var maxZ = 1268;
2. 解決方案
WebGL是OpenGL的子集,因此我這裡直接用WebGL的例子,但是各種介面函式跟OpenGL是非常類似的,尤其是圖形變換的函式。
1) Cube.html
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="utf-8" /> <title>Hello cube</title> </head> <body onload="main()"> <canvas id="webgl" width="600" height="600"> Please use a browser that supports "canvas" </canvas> <script src="lib/webgl-utils.js"></script> <script src="lib/webgl-debug.js"></script> <script src="lib/cuon-utils.js"></script> <script src="lib/cuon-matrix.js"></script> <script src="Cube.js"></script> </body> </html>
2) Cube.js
// Vertex shader program var VSHADER_SOURCE = 'attribute vec4 a_Position;\n' + 'attribute vec4 a_Color;\n' + 'uniform mat4 u_MvpMatrix;\n' + 'varying vec4 v_Color;\n' + 'void main() {\n' + 'gl_Position = u_MvpMatrix * a_Position;\n' + 'v_Color = a_Color;\n' + '}\n'; // Fragment shader program var FSHADER_SOURCE = '#ifdef GL_ES\n' + 'precision mediump float;\n' + '#endif\n' + 'varying vec4 v_Color;\n' + 'void main() {\n' + 'gl_FragColor = v_Color;\n' + '}\n'; //包圍盒範圍 var minX = 399589.072; var maxX = 400469.072; var minY = 3995118.062; var maxY = 3997558.062; var minZ = 732; var maxZ = 1268; //包圍盒中心 var cx = (minX + maxX) / 2.0; var cy = (minY + maxY) / 2.0; var cz = (minZ + maxZ) / 2.0; //當前lookAt()函式初始視點的高度 var eyeHight = 2000.0; //根據視點高度算出setPerspective()函式的合理角度 var fovy = (maxY - minY) / 2.0 / eyeHight; fovy = 180.0 / Math.PI * Math.atan(fovy) * 2; //setPerspective()遠截面 var far = 3000; // function main() { // Retrieve <canvas> element var canvas = document.getElementById('webgl'); // Get the rendering context for WebGL var gl = getWebGLContext(canvas); if (!gl) { console.log('Failed to get the rendering context for WebGL'); return; } // Initialize shaders if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) { console.log('Failed to intialize shaders.'); return; } // Set the vertex coordinates and color var n = initVertexBuffers(gl); if (n < 0) { console.log('Failed to set the vertex information'); return; } // Get the storage location of u_MvpMatrix var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix'); if (!u_MvpMatrix) { console.log('Failed to get the storage location of u_MvpMatrix'); return; } // Register the event handler var currentAngle = [0.0, 0.0]; // Current rotation angle ([x-axis, y-axis] degrees) initEventHandlers(canvas, currentAngle); // Set clear color and enable hidden surface removal gl.clearColor(0.0, 0.0, 0.0, 1.0); gl.enable(gl.DEPTH_TEST); // Start drawing var tick = function () { //setPerspective()寬高比 var aspect = canvas.width / canvas.height; // draw(gl, n, aspect, u_MvpMatrix, currentAngle); requestAnimationFrame(tick, canvas); }; tick(); } function initEventHandlers(canvas, currentAngle) { var dragging = false;// Dragging or not var lastX = -1, lastY = -1;// Last position of the mouse // Mouse is pressed canvas.onmousedown = function (ev) { var x = ev.clientX; var y = ev.clientY; // Start dragging if a moue is in <canvas> var rect = ev.target.getBoundingClientRect(); if (rect.left <= x && x < rect.right && rect.top <= y && y < rect.bottom) { lastX = x; lastY = y; dragging = true; } }; //滑鼠離開時 canvas.onmouseleave = function (ev) { dragging = false; }; // Mouse is released canvas.onmouseup = function (ev) { dragging = false; }; // Mouse is moved canvas.onmousemove = function (ev) { var x = ev.clientX; var y = ev.clientY; if (dragging) { var factor = 100 / canvas.height; // The rotation ratio var dx = factor * (x - lastX); var dy = factor * (y - lastY); // Limit x-axis rotation angle to -90 to 90 degrees //currentAngle[0] = Math.max(Math.min(currentAngle[0] + dy, 90.0), -90.0); currentAngle[0] = currentAngle[0] + dy; currentAngle[1] = currentAngle[1] + dx; } lastX = x, lastY = y; }; //滑鼠縮放 canvas.onmousewheel = function (event) { var lastHeight = eyeHight; if (event.wheelDelta > 0) { eyeHight = Math.max(1, eyeHight - 80); } else { eyeHight = eyeHight + 80; } far = far + eyeHight - lastHeight; }; } function draw(gl, n, aspect, u_MvpMatrix, currentAngle) { //模型矩陣 var modelMatrix = new Matrix4(); modelMatrix.rotate(currentAngle[0], 1.0, 0.0, 0.0); // Rotation around x-axis modelMatrix.rotate(currentAngle[1], 0.0, 1.0, 0.0); // Rotation around y-axis modelMatrix.translate(-cx, -cy, -cz); //檢視矩陣 var viewMatrix = new Matrix4(); viewMatrix.lookAt(0, 0, eyeHight, 0, 0, 0, 0, 1, 0); //投影矩陣 var projMatrix = new Matrix4(); projMatrix.setPerspective(fovy, aspect, 10, far); //模型檢視投影矩陣 var mvpMatrix = new Matrix4(); mvpMatrix.set(projMatrix).multiply(viewMatrix).multiply(modelMatrix); // Pass the model view projection matrix to u_MvpMatrix gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements); // Clear color and depth buffer gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Draw the cube gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0); } function initVertexBuffers(gl) { // Create a cube //v6----- v5 ///|/| //v1------v0| //| || | //| |v7---|-|v4 //|/|/ //v2------v3 var verticesColors = new Float32Array([ // Vertex coordinates and color maxX, maxY, maxZ, 1.0, 1.0, 1.0,// v0 White minX, maxY, maxZ, 1.0, 0.0, 1.0,// v1 Magenta minX, minY, maxZ, 1.0, 0.0, 0.0,// v2 Red maxX, minY, maxZ, 1.0, 1.0, 0.0,// v3 Yellow maxX, minY, minZ, 0.0, 1.0, 0.0,// v4 Green maxX, maxY, minZ, 0.0, 1.0, 1.0,// v5 Cyan minX, maxY, minZ, 0.0, 0.0, 1.0,// v6 Blue minX, minY, minZ, 1.0, 0.0, 1.0// v7 Black ]); // Indices of the vertices var indices = new Uint8Array([ 0, 1, 2, 0, 2, 3,// front 0, 3, 4, 0, 4, 5,// right 0, 5, 6, 0, 6, 1,// up 1, 6, 7, 1, 7, 2,// left 7, 4, 3, 7, 3, 2,// down 4, 7, 6, 4, 6, 5// back ]); // Create a buffer object var vertexColorBuffer = gl.createBuffer(); var indexBuffer = gl.createBuffer(); if (!vertexColorBuffer || !indexBuffer) { return -1; } // Write the vertex coordinates and color to the buffer object gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer); gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW); var FSIZE = verticesColors.BYTES_PER_ELEMENT; // Assign the buffer object to a_Position and enable the assignment var a_Position = gl.getAttribLocation(gl.program, 'a_Position'); if (a_Position < 0) { console.log('Failed to get the storage location of a_Position'); return -1; } gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0); gl.enableVertexAttribArray(a_Position); // Assign the buffer object to a_Color and enable the assignment var a_Color = gl.getAttribLocation(gl.program, 'a_Color'); if (a_Color < 0) { console.log('Failed to get the storage location of a_Color'); return -1; } gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 6, FSIZE * 3); gl.enableVertexAttribArray(a_Color); // Write the indices to the buffer object gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); return indices.length; }
3) 執行結果
這份程式碼改進《WebGL程式設計指南》一書裡面繪製一個簡單立方體的例子,引用的幾個JS-lib也是該書提供。本例全部原始碼地址連結為: https://share.weiyun.com/52XmsFv ,密碼:h1lbay。
用chrome開啟Cube.html,會出現一個長方體的包圍盒,還可以用滑鼠左鍵旋轉,滑鼠滾輪縮放:

3. 詳細講解
本例的思路是通過JS的requestAnimationFrame()函式不停的呼叫繪製函式draw(),同時將一些變數關聯到滑鼠操作事件和draw(),達到頁面圖形變換的效果。這裡筆者就不講原理,重點講一講設定三個圖形變換的具體過程,網上已經有非常多的原理介紹了。
1) 模型變換
在draw()函式中設定模型矩陣:
//模型矩陣 var modelMatrix = new Matrix4(); modelMatrix.rotate(currentAngle[0], 1.0, 0.0, 0.0); // Rotation around x-axis modelMatrix.rotate(currentAngle[1], 0.0, 1.0, 0.0); // Rotation around y-axis modelMatrix.translate(-cx, -cy, -cz);
由於這個包圍盒(長方體)的座標值都非常大,所以第一步需要對其做平移變換translate(-cx, -cy, -cz),cx,cy,cz就是包圍盒的中心:
//包圍盒中心 var cx = (minX + maxX) / 2.0; var cy = (minY + maxY) / 2.0; var cz = (minZ + maxZ) / 2.0;
接下來是旋轉變換,陣列currentAngle記錄了繞X軸和Y軸旋轉的角度,初始值為0。配合onmousedown,onmouseup,onmousemove三個滑鼠事件,將頁面滑鼠X、Y方向的移動,轉換成繞X軸,Y軸的角度值,累計到currentAngle中,從而實現了三維模型隨滑鼠旋轉。
// Mouse is moved canvas.onmousemove = function (ev) { var x = ev.clientX; var y = ev.clientY; if (dragging) { var factor = 100 / canvas.height; // The rotation ratio var dx = factor * (x - lastX); var dy = factor * (y - lastY); // Limit x-axis rotation angle to -90 to 90 degrees //currentAngle[0] = Math.max(Math.min(currentAngle[0] + dy, 90.0), -90.0); currentAngle[0] = currentAngle[0] + dy; currentAngle[1] = currentAngle[1] + dx; } lastX = x, lastY = y; };
注意模型矩陣的平移變換要放後面,需要把座標軸換到包圍盒中心,才能繞三維模型自轉。
2) 檢視變換
通過lookAt()函式設定檢視矩陣:
//當前lookAt()函式初始視點的高度 var eyeHight = 2000.0; // … //檢視矩陣 var viewMatrix = new Matrix4(); viewMatrix.lookAt(0, 0, eyeHight, 0, 0, 0, 0, 1, 0);
檢視變換調整的是觀察者的狀態,lookAt()函式分別設定了視點、目標觀察點以及上方向。雖然可以在任何位置去觀察三維場景的點,從而得到渲染結果。但在實際的應用當中,這個函式設定的結果很難以想象,所以筆者設定成,觀察者站在包圍盒中心上方的位置,對準座標系原點(注意這個時候經過模型變換,包圍盒的中心點已經是座標系原點了),常見的Y軸作為上方向。這樣,檢視內無論如何都是可見的。
這裡將視點的高度設定成變數eyeHight,初始值為2000,是一個大於0的經驗值。同時通過滑鼠的滾輪事件onmousewheel()調整該值,從而實現三維模型的縮放的:
//滑鼠縮放 canvas.onmousewheel = function (event) { var lastHeight = eyeHight; if (event.wheelDelta > 0) { eyeHight = Math.max(1, eyeHight - 80); } else { eyeHight = eyeHight + 80; } };
3) 投影變換
通過setPerspective()來設定投影變換:
//根據視點高度算出setPerspective()函式的合理角度 var fovy = (maxY - minY) / 2.0 / eyeHight; fovy = 180.0 / Math.PI * Math.atan(fovy) * 2; //setPerspective()遠截面 var far = 3000; //setPerspective()寬高比 var aspect = canvas.width / canvas.height; //... //投影矩陣 var projMatrix = new Matrix4(); projMatrix.setPerspective(fovy, aspect, 10, far);
前面的檢視變換已經論述了,這個模型是在中心點上方去觀察中心點,相當於視線垂直到前介面near的表面,那麼setPerspective()就可以確定其角度fovy了,示意圖如下:
很明顯的看出,當光線射到包圍盒的中心,包圍盒Y方向長度的一半,除以視點高,就是fovy一般的正切值。
寬高比aspect即是頁面canvas元素的寬高比。
近介面near一般設定成較近的值,但是不能太近(比如小於1),否則會影響深度判斷的精度造成頁面閃爍。 《OpenGL繪製紋理,縮放相機導致紋理閃爍的解決方法gluPerspective ()》 論述了這個問題。
而遠介面far也是需要跟著滑鼠滾輪一起變換的,否則當eyeHight變大,三維物體會逐漸離開透視變換的視錐體:
//滑鼠縮放 canvas.onmousewheel = function (event) { var lastHeight = eyeHight; if (event.wheelDelta > 0) { eyeHight = Math.max(1, eyeHight - 80); } else { eyeHight = eyeHight + 80; } far = far + eyeHight - lastHeight; };
4) 模型檢視投影矩陣
將三個矩陣都應用起來,就得到最終的模型檢視投影矩陣。注意計算式是:投影矩陣 * 檢視矩陣 * 模型矩陣:
//模型檢視投影矩陣 var mvpMatrix = new Matrix4(); mvpMatrix.set(projMatrix).multiply(viewMatrix).multiply(modelMatrix);
4. 存在問題
本例中的三維物體隨著滑鼠旋轉,是把滑鼠X、Y方向的移動距離轉換成繞X軸,Y軸方向的角度來實現的。但是如何用滑鼠實現繞Z軸(第三軸)旋轉呢?例如像OSG這樣的渲染引擎,是可以用滑鼠繞第三個軸旋轉的(當然操作有點費力)。這裡希望大家能批評指正下。