【零基礎】充分理解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(); ```

https://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,這樣最終運行的結果如下:

https://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(); ```

https://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(); ```

https://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(); ```

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

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

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

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