上次文章介紹瞭如何用webgl快速建立一個自己的小世界,在我們入門webgl之後,並且可以用原生webgl寫demo越來越複雜之後,大家可能會糾結一點:就是我使用webgl的姿勢對不對。因為webgl可以操控shader加上超底層API,帶來了一個現象就是同樣一個東西,可以有多種的實現方式,而此時我們該如何選擇呢?這篇文章將稍微深入一點webgl,給大家介紹一點webgl的優化知識。
講webgl優化之前我們先簡單回憶一下canvas2D的優化,常用的display list、動態區域重繪等等。用canvas2D多的同學應該對以上的優化或多或少都有了解,但是你對webgl的優化了解麼,如果不瞭解的話往下看就對了~這裡會先從底層影象是如何渲染到螢幕上開始,逐步開始我們的webgl優化。
gpu如何渲染出一個物體
先看一個簡單的球的例子,下面是用webgl畫出來的一個球,加上了一點光的效果,程式碼很簡單,這裡就不展開說了。
一個球
這個球是一個簡單的3D模型,也沒有複雜的一些變化,所以例子中的球效能很好,看FPS值穩定在60。後面我們會嘗試讓它變得複雜起來,然後進行一些優化,不過這一節我們得先了解渲染的原理,知其根本才能知道優化的原理。
我們都知道webgl與著色器是密不可分的關係,webgl當中有頂點著色器和片段著色器,下面用一張圖來簡單說明下一個物體由0到1生成的過程。
0就是起點,對應圖上面的3D mesh,在程式中這個就是3D頂點資訊
1就是終點,對應圖上面的Image Output,此時已經渲染到螢幕上了
我們重點是關注中間那三個階段,第一個是一個標準的三角形,甚至三角形上面用三個圈指明瞭三個點,再加上vertex關鍵字,可以很明白的知道是頂點著色器處理的階段,圖翻譯為大白話就是:
我們將頂點資訊傳給頂點著色器(drawElements/drawArray),然後著色器將頂點資訊處理並開始畫出三角形(gl_Position)
然後再看後兩個圖,很明顯的fragments關鍵字指明瞭這是片元著色器階段。Rasterization是光柵化,從圖上直觀的看就是三角形用三條線表示變成了用畫素表示,其實實際上也是如此,更詳細的可以看下面地址,這裡不進行展開。
如何理解光柵化-知乎
後面階段是上色,可以用textture或者color都可以,反正統一以rgba的形式賦給gl_FragColor
圖中vertexShader會執行3次,而fragmentShader會執行35次(有35個方塊)
發現fragmentShader執行次數遠遠超過vertexShader,此時機智的朋友們肯定就想到儘可能的將fragmentShader中的計算放在vertexShader中,但是能這樣玩麼?
強行去找還是能找到這樣的場景的,比如說反射光。反射光的計算其實不是很複雜,但也稍微有一定的計算量,看核心程式碼
1 2 3 4 5 6 7 8 9 10 11 |
vec3 L = normalize(uLightDirection); vec3 N = normalize(vNormal); float lambertTerm = dot(N, -L); vIs = vec4(0.0, 0.0, 0.0, 1.0); if (lambertTerm > 0.0) { vec3 E = normalize(vEye); vec3 R = reflect(L, N); float specular = pow(max(dot(R, E), 0.0), uShininess); vIs = uLightSpecular * uMaterialSpecular * specular; } |
上面反射光程式碼就不細說了,核心就是內建的reflect方法。這段程式碼既可以放在fragmentShader中也可以放在vertexShader中,但是二者的結果有些不同,結果分別如下
放在vertexShader中
放在fragmentShader中
所以說這裡的優化是有缺陷的,可以看到vertexShader中執行光計算和fragmentShader中執行生成的結果區別還是蠻大的。換言之如果想要實現真實反射光的效果,必須在fragmentShader中去計算。開頭就說了這篇文章的主題在同樣的一個效果,用什麼方式是最優的,所以continue~
gpu計算能力很猛
上一節說了gpu渲染的原理,這裡再隨便說幾個gpu相關的新聞
百度人工智慧大規模採用gpu,PhysX碰撞檢測使用gpu提速……種種類似的現象都表明了gpu在單純的計算能力上是超過普通的cpu,而我們關注一下前一節shader裡面的程式碼
vertexShader
1 2 3 4 5 6 7 |
void main() { vec4 vertex = uMMatrix * uRMatrix * vec4(aPosition, 1.0); vNormal = vec3(uNMMatrix * uNRMatrix * vec4(aNormal, 1.0)); vEye = -vec3((uVMatrix * vertex).xyz); gl_Position = uPMatrix * uVMatrix * vertex; } |
fragmentShader
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
void main() { vec3 L = normalize(uLightDirection); vec3 N = normalize(vNormal); float lambertTerm = dot(N, -L); vec4 Ia = uLightAmbient * uMaterialAmbient; vec4 Id = vec4(0.0, 0.0, 0.0, 1.0); vec4 Is = vec4(0.0, 0.0, 0.0, 1.0); if (lambertTerm > 0.0) { Id = uLightDiffuse * uMaterialDiffuse * lambertTerm; vec3 E = normalize(vEye); vec3 R = reflect(L, N); float specular = pow(max(dot(R, E), 0.0), uShininess); Is = uLightSpecular * uMaterialSpecular * specular; } vec4 finalColor = Ia + Id + Is; finalColor.a = 1.0; gl_FragColor = finalColor; } |
可以發現邏輯語句很少,更多的都是計算,特別是矩陣的運算,兩個mat4相乘通過js需要寫成這樣(程式碼來自glMatrix)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
mat4.multiply = function (mat, mat2, dest) { if (!dest) { dest = mat } // Cache the matrix values (makes for huge speed increases!) var a00 = mat[0], a01 = mat[1], a02 = mat[2], a03 = mat[3]; var a10 = mat[4], a11 = mat[5], a12 = mat[6], a13 = mat[7]; var a20 = mat[8], a21 = mat[9], a22 = mat[10], a23 = mat[11]; var a30 = mat[12], a31 = mat[13], a32 = mat[14], a33 = mat[15]; var b00 = mat2[0], b01 = mat2[1], b02 = mat2[2], b03 = mat2[3]; var b10 = mat2[4], b11 = mat2[5], b12 = mat2[6], b13 = mat2[7]; var b20 = mat2[8], b21 = mat2[9], b22 = mat2[10], b23 = mat2[11]; var b30 = mat2[12], b31 = mat2[13], b32 = mat2[14], b33 = mat2[15]; dest[0] = b00 * a00 + b01 * a10 + b02 * a20 + b03 * a30; dest[1] = b00 * a01 + b01 * a11 + b02 * a21 + b03 * a31; dest[2] = b00 * a02 + b01 * a12 + b02 * a22 + b03 * a32; dest[3] = b00 * a03 + b01 * a13 + b02 * a23 + b03 * a33; dest[4] = b10 * a00 + b11 * a10 + b12 * a20 + b13 * a30; dest[5] = b10 * a01 + b11 * a11 + b12 * a21 + b13 * a31; dest[6] = b10 * a02 + b11 * a12 + b12 * a22 + b13 * a32; dest[7] = b10 * a03 + b11 * a13 + b12 * a23 + b13 * a33; dest[8] = b20 * a00 + b21 * a10 + b22 * a20 + b23 * a30; dest[9] = b20 * a01 + b21 * a11 + b22 * a21 + b23 * a31; dest[10] = b20 * a02 + b21 * a12 + b22 * a22 + b23 * a32; dest[11] = b20 * a03 + b21 * a13 + b22 * a23 + b23 * a33; dest[12] = b30 * a00 + b31 * a10 + b32 * a20 + b33 * a30; dest[13] = b30 * a01 + b31 * a11 + b32 * a21 + b33 * a31; dest[14] = b30 * a02 + b31 * a12 + b32 * a22 + b33 * a32; dest[15] = b30 * a03 + b31 * a13 + b32 * a23 + b33 * a33; return dest; }; |
可以說相比普通的加減乘除來說矩陣相關的計算量還是有點大的,而gpu對矩陣的計算有過專門的優化,是非常快的
所以我們第一反應肯定就是能在shader中乾的活就不要讓js折騰啦,比如說前面程式碼中將proMatrix/viewMatrix/modelMatrix都放在shader中去計算。甚至將modelMatrix裡面再區分成moveMatrix和rotateMatrix可以更好的去維護不是麼~
但是瞭解threejs或者看其他學習資料的的同學肯定知道threejs會把這些計算放在js中去執行,這是為啥呢??比如下方程式碼(節選自webgl程式設計指南)
vertexShader中
1 2 3 4 5 6 |
…… attribute vec4 u_MvpMatrix;…… void main() { gl_Position = u_MvpMatrix * a_Position; } …… |
javascript中
1 2 3 4 5 6 |
…… var mvpMatrix = new Matrix4(); mvpMatrix.setPerspective(30, canvas.width / canvas.height, 1, 100); mvpMatrix.lookAt(3, 3, 7, 0, 0, 0, 0, 1, 0); gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements); …… |
這裡居然把proMatrix/viewMatrix/modelMatrix全部在js中計算好,然後傳入到shader中去,為什麼要這樣呢?
結合第一節我們看下vertexShader執行的次數是和頂點有關係的,而每個頂點都需要做物件座標->世界座標->眼睛座標的變換,如果傳入三個頂點,就代表gpu需要將proMatrix * viewMatrix * modelMatrix計算三次,而如果我們在js中就計算好,當作一個矩陣傳給gpu,則是極好的。js中雖然計算起來相較gpu慢,但是勝在次數少啊。
看下面兩個結果
第一個是將矩陣都傳入給gpu去計算的,我這邊看到FPS維持在50左右
第二個是將部分矩陣計算在js中完成的,我這邊看到FPS維持在60樣的
這裡用的180個球,如果球的數量更大,區別還可以更加明顯。所以說gpu計算雖好,但不要濫用呦~
js與shader互動的成本
動畫就是畫一個靜態場景然後擦掉接著畫一個新的,重複不斷。第一節中我們用的是setInterval去執行的,每一個tick中我們必須的操作就是更新shader中的attribute或者uniform,這些操作是很耗時的,因為是js和glsl程式去溝通,此時我們想一想,有沒有什麼可以優化的地方呢?
比如有一個場景,同樣是一個球,這個球的材質顏色比較特殊
x,y方向上都有著漸變,不再是第一節上面一個色的了,此時我們該怎麼辦?
首先分析一下這個這個球
總而言之就是水平和垂直方向都有漸變,如果按之前的邏輯擴充套件,就意味著我們得有多個uniform去標識
我們先嚐試一下,用如下的程式碼,切換uniform的方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
…… var colorArr = []; var temp; for (i = 1; i <= granularity; i++) { temp = 0.8 - (i / granularity * 0.7); for (j = 1; j <= granularity; j++) { colorArr.push([0.8 - (j / granularity * 0.7), temp, 0.1, 1.0]); } } …… for (i = 0; i < granularity; i++) { for (j = 0; j < granularity; j++) { webgl.uniform4fv(uMaterialDiffuse, colorArr[i * granularity + j]); webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, (i * granularity * 6 + j * 6) * 2); } } |
發現FPS在40左右,還是蠻卡的。然後我們考慮一下,卡頓在哪?
vertexShader和fragmentShader執行的次數可以說都是一樣的,但是uniform4fv和drawElements每一次tick中執行了多次,就代表著js與shader耗費了較大的時間。那我們應該如何優化呢?
核心在避免多次改變uniform,比方說我們可以嘗試用attribute去代替uniform
看下結果怎樣
瞬間FPS就上去了對不~所以說靈活變通很重要,不能一味的死板,儘可能的減少js與shader的互動對效能的提高是大大有幫助的~
切換program的成本
上一節我們發現頻繁切換切換uniform的開銷比較大,有沒有更大的呢?
當然有,那就是切換program,我們把之前的例子用切換program的方式試下,直接看下面的例子
點選前慎重,可能會引起瀏覽器崩潰
切換program
已經不需要關心FPS的了,可以直觀的感覺到奇卡無比。切換program的成本應該是在webgl中開銷是非常大的了,所以一定要少切換program
這裡說的是少切換program,而不是說不要切換program,從理論上來說可以單個program寫完整個程式的呀,那什麼時候又需要切換program呢?
program的作用是代替if else語句,相當於把if else抽出來單獨一個program,所以就是如果一個shader裡面的if else多到開銷超過program的開銷,此時我們就能選擇用program啦。
當然這裡的度有點難把握,需要開發者自己多嘗試,結合實際情況進行選擇。這裡有一個關於選擇program還是if else的討論,感興趣的同學可以看看
結語
我們這裡從原理觸發,嘗試了webgl的一些優化~如果你有什麼建議和疑惑~歡迎留言討論~