【零基礎】充分理解WebGL(一)

語言: CN / TW / HK

在前端方向裡,WebGL算是典型的小眾領域,真正理解的人並不多。甚至一些拿Three.js寫3D應用的同學,對WebGL本身也是一知半解。

之所以這樣,是因為WebGL的技術棧和傳統的Web前端技術有極大的差別。相對而言,傳統Web前端使用的API比較高階,不存在太多需要理解的底層原理合概念,而WebGL的核心是OpenGL,它是OpenGL在Web上的實現。OpenGL是通過操作GPU來完成圖形繪製渲染的,因此它的API相對比較底層,使用起來較為繁瑣,這使得一些習慣於前端開發的工程師很難適應,所以就會覺得學習門檻較高。

實際上,要理解和學會WebGL,並沒有那麼困難,我們只需要理解一下GPU,瞭解它與CPU的不同點,然後再理解執行GPU程式碼的語言——glsl,瞭解著色器的基本概念和用法,就可以輕鬆理解WebGL的本質原理,然後在花一點時間和耐心,慢慢學習WebGL的API,就可以掌握WebGL這門技術了。

那麼什麼是GPU,它與CPU有什麼不同?我們通過下面幾張圖來看一下:

t0149eca869be61bc44.webp

上面這張圖,是CPU的工作原理,它就像是一個管道,資料(圖中的箱子)從左側輸入,在CPU中完成處理,然後從右側輸出。CPU是由多個這樣的管道構成的,每個管道我們叫做一個CPU核心,如果你開啟你電腦的作業系統,檢視本機資訊,你可能會看到類似這樣的資訊:2.6 GHz 六核Intel Core i7,這裡的六核,你可以理解成有6個這樣的管道,因此可以同時處理6個任務。

CPU的工作能力,與管道本身的處理速度(頻率)合管道的數量(核心數)有關係,頻率越高,那麼運算處理單一任務的速度就越快,核心數越多,那麼能同時並行處理的任務數就越多。

雖然現代計算機的CPU運算能力很強,但是它也有侷限性,對於某些場景,它並不擅長,比如圖形渲染

我們知道,計算機影象是由畫素構成,所謂畫素,可以簡單理解為最終呈現在顯示裝置上的一個1x1的顏色小方塊。

Licorne_Pixel_Art_Dab_large.webp

現在的顯示裝置非常先進,可以用非常多的畫素小方塊來精確構圖。前端的CSS中的px單位,就是畫素單位,一張800px長、600px寬的圖片,邏輯上是由600*800,也就是48萬個畫素點構成的。如果要對這張圖片的畫素進行計算,用CPU來運算的話,單核CPU需要處理48萬個微小任務,就像下面這張圖:

t01f6e6963ceec21073.webp

不是說CPU不能完成這樣的處理,每一個畫素的計算可能是非常簡單的(只是處理一下顏色),但是數量太多,對CPU這樣的結構也會造成負擔。因此,在這個時候,另外一種高併發結構,也就是GPU就登場了。

t01d1ab4a0e55fa8cac.webp

與CPU不同,GPU可以看成是由數量非常多的微小管道構成的結構,每一個管道恰好可以處理“一粒沙子”,這樣,如果對於一張600畫素x800畫素的圖片,有48萬個管道組成的GPU,就可以同時處理這48萬個畫素點了!事實上,GPU幾乎就是這樣做的。

mermaid flowchart LR 準備資料 --> 著色處理 --> 幀緩衝 --> 渲染輸出

WebGL利用JavaScript來準備資料,將資料通過共享資料結構(ArrayBuffer) 傳遞給GPU,由GPU進行著色處理,然後再將處理後的資料輸出到幀緩衝區,最後再渲染出來。

這其中的關鍵是頭兩個步驟,也就是準備資料和著色處理,其中準備資料一般是通過JavaScript的型別陣列(TypedArray),著色處理是通過WebGL Program執行一種特殊的glsl語言來實現。在WebGL中,著色階段通常分成兩步,分別是頂點著色和片段著色。

我前面也說過,WebGL的API比較底層,所以操作起來比較繁瑣,但是也有許多JS庫,幫我們封裝了基本的操作,因此在這裡,我們可以先跳過繁瑣的API部分,利用我簡單封裝的一個開源庫gl-renderer來學習一下WebGL的資料和著色部分。

兩個著色器

動手實踐是最好的理解問題的方法之一,所以我們來動手寫一寫程式碼。

```js const canvas = document.querySelector('canvas'); const renderer = new GlRenderer(canvas, {webgl2: true});

const fragment = #version 300 es precision highp float; out vec4 FragColor; void main() { FragColor = vec4(1, 0, 0, 1); }; const program = renderer.compileSync(fragment); renderer.useProgram(program); renderer.render(); ```

http://code.juejin.cn/pen/7098235100726296589

上面的程式碼裡面,JS的部分很好理解,但是其中有一段fragment變數中的字串,是需要我們關注的部分,沒錯,它就是兩個著色器之一的片段著色器程式碼。

這段程式碼是用glsl語言寫的,但是並不難理解,第一句 #version 300 es 宣告這個著色器是webgl 2.0版本的著色器,瀏覽器目前同時支援webgl 1.0和webgl 2.0兩個版本,它們有一些差異,但差異不是很大。

第二句precision highp float;表示設定浮點數精度為高精度,這個可以暫時忽略,以後系列文章中再詳細講解。

第三句out vec4 FragColor 宣告FragColor是輸出變數,它的型別是vec4,是一個四維向量,用來表示一個RGBA顏色值,它與CSS的顏色區別是,CSS的RGB值是0到255,Alpha值是0到1,但是在著色器裡面,RGBA的值都是從0到1。

void main是主函式,vec4(1, 0, 0, 1) 表示將 FragColor 設定為紅色。

👉🏻 你可以試著修改碼上掘金中的程式碼,把 vec4(1, 0, 0, 1) 改成 vec4(0, 1, 0, 1),看看會發生什麼?

在這裡有必要解釋一下為什麼這麼設定整個畫布會變成紅色。還記得前面說的,GPU是平行計算的,也就是說,這段著色器程式碼,是每個畫素都並行執行(嚴格來說是根據圖元裝配的結果來執行對應區域的畫素,但在這裡我們先略過這一點),因此,整個畫布中,所有的畫素點,你可以認為都執行了一遍上面的著色器程式碼,而且是同時、並行執行的,由於是無差別執行的設定顏色為紅色,因此整塊畫布都成為了紅色的。

我們可以修改一下上面例子的程式碼,看一下給每個畫素設定不同顏色的情況:

```js const canvas = document.querySelector('canvas'); const renderer = new GlRenderer(canvas, {webgl2: true});

const fragment = #version 300 es precision highp float; out vec4 FragColor; uniform vec2 resolution; void main() { vec2 st = gl_FragCoord.xy / resolution; FragColor = vec4(st, 0, 1); }; const program = renderer.compileSync(fragment); renderer.useProgram(program); renderer.uniforms.resolution = [canvas.width, canvas.height]; renderer.render(); ```

我們修改了上面的程式碼,在著色器中增加了一個uniform vec2 resolution,這是聲明瞭一個resolution變數,它的型別是二維向量,我們通過renderer.uniforms.resolution將畫布的寬高傳入。

gl_FragCoord.xy是一個內建變數,它表示當前渲染的畫素在畫布內的座標,左下角是[0,0],右上角是[width,height],所以gl_FragCoord.xy / resolution可以將座標值“歸一”(即將值限制到0~1區間,這是一種在寫著色器的時候經常使用的數學技巧)。然後我們將st的值傳給FragColor,這樣最終執行的結果如下:

http://code.juejin.cn/pen/7098245656740888612

通過這個例子,我們可以簡單理解片段著色器的執行方式,片段著色器對圖形十分重要,在後續的文章裡,我們還有很多技巧需要逐步學習。

但是我們現在先回過頭來,解決一個問題,為什麼這裡片段著色器對整個canvas生效?

所以,接下來我們就來了解另一個著色器:頂點著色器。

還是老辦法,動手實踐——讓我們來修改程式碼:

```js const canvas = document.querySelector('canvas'); const renderer = new GlRenderer(canvas, {webgl2: true});

const fragment = #version 300 es precision highp float; out vec4 FragColor; void main() { FragColor = vec4(1, 0, 0, 1); }; const program = renderer.compileSync(fragment); renderer.useProgram(program); renderer.setMeshData([ { positions: [[0, 1, 0], [-1, -1, 0], [1, -1, 0]], cells: [[0, 1, 2]], }, ]); renderer.render(); ```

http://code.juejin.cn/pen/7098248752191766542

在這裡,我們沒有新增頂點著色器,只是給renderer設定了一些資料,在資料裡我們指定了三個頂點,它們的三維座標分別是[0, 1, 0], [-1, -1, 0], [1, -1, 0],這裡我們沒有用到z軸,所以z保持0,x和y在WebGL中,預設範圍是從-1到1,所以WebGL的平面座標系原點在中心,左下角是[-1,-1],右下角是[1, -1],右上角是[1, 1],左上角是[-1,1]。我們設定了position這三個頂點之後,繪製在畫面上的就變成了一個三角形。

之所以我們沒有指定頂點著色器,是因為gl-renderer有預設的頂點著色器,程式碼如下:

```glsl

version 300 es

precision highp float; precision highp int;

in vec3 a_vertexPosition;

void main() { gl_PointSize = 1.0; gl_Position = vec4(a_vertexPosition, 1); } ```

我們也可以指定頂點著色器,我們可以繼續修改程式碼:

```js const canvas = document.querySelector('canvas'); const renderer = new GlRenderer(canvas, {webgl2: true});

const vertex = `#version 300 es precision highp float; precision highp int;

in vec3 a_vertexPosition;

void main() { gl_PointSize = 1.0; gl_Position = vec4(0.5 * a_vertexPosition, 1); }`;

const fragment = #version 300 es precision highp float; out vec4 FragColor; void main() { FragColor = vec4(1, 0, 0, 1); }; const program = renderer.compileSync(fragment, vertex); renderer.useProgram(program); renderer.setMeshData([ { positions: [[0, 1, 0], [-1, -1, 0], [1, -1, 0]], cells: [[0, 1, 2]], }, ]); renderer.render(); ```

http://code.juejin.cn/pen/7098253710907670564

上面的程式碼我們指定了頂點著色器,在它裡面我們把a_vertexPosition,也就是我們傳入的頂點座標給乘以了0.5,所以最終繪製出來的三角形周長就是原來的1/2。

為什麼是三角形?

因為三角形是WebGL的基本圖元,WebGL支援點、線、三角形等基本圖元。下面的程式碼我們更換了圖元。

```js const canvas = document.querySelector('canvas'); const renderer = new GlRenderer(canvas, {webgl2: true});

const vertex = `#version 300 es precision highp float; precision highp int;

in vec3 a_vertexPosition;

void main() { gl_PointSize = 1.0; gl_Position = vec4(0.5 * a_vertexPosition, 1); }`;

const fragment = #version 300 es precision highp float; out vec4 FragColor; void main() { FragColor = vec4(1, 0, 0, 1); }; const program = renderer.compileSync(fragment, vertex); renderer.useProgram(program); renderer.setMeshData([ { mode: 'LINE_STRIP', positions: [[0, 1, 0], [-1, -1, 0], [1, -1, 0]], cells: [[0, 1, 2, 0]], }, ]); renderer.render(); ```

http://code.juejin.cn/pen/7098254635743313927

最後剩下一個問題,我們預設不設定頂點的時候,繪製的圖形是整個canvas範圍,但是WebGL並不支援四邊形圖元,那麼我們原本的繪製範圍是如何界定的呢?

我把這個問題作為本篇文章的課後問題,留給下一講來解決吧。

以上內容有任何問題,歡迎在評論區討論。