大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 5 篇文章。本系列文章從 WebGL 之基礎開始介紹,最後建構出繪製 3D、光影效果之網頁。本章節講述的是 WebGL 基本的運作機制以及如何使用其提供的功能,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
Day 3 畫出三角形時,在 positionBuffer
中傳入了 3 個頂點,每個頂點分別有兩個值 (x, y) 表示座標乘下來共 6 個值,並且在 gl.drawArrays()
的最後一個參數 count
參數傳入 3 表示畫三個頂點;若要畫更多三角形,我想讀者也已經想到,分別在 positionBuffer
傳入更多『組』三角形的每個頂點座標,接著修改 gl.drawArrays()
的 count
即可,筆者直接畫三個三角形:
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
150, 60,
180, 82.5,
120, 82.5,
100, 60,
80, 40,
120, 40,
200, 60,
180, 40,
220, 40,
]),
gl.STATIC_DRAW,
);
// ...
gl.drawArrays(gl.TRIANGLES, 0, 9);
看起來像是這樣:
這些只是筆者隨便想到的圖案,讀者們可以自行發揮想像力調整頂點座標
筆者當時很好奇:如果 buffer 資料長度不足,或是 count
不是三的倍數,那麼會怎麼樣呢?如果把最後一組頂點刪除(220, 40
那組),count
保持為 9,不僅不會有什麼陣列超出的錯誤,感覺上 vertex attribute array 還給不足的部份填上預設值 0
:
或者把 count
改成 8,也不會有錯誤,只是最後一組三角形沒有完整,兩個點湊不出一個面,因此最後一個三角形就消失了:
在 Day 2 我們實做的 fragment shader 只是純粹把顏色指定上去,所以現在不論畫幾個三角形,顏色都是當初寫死在 fragment shader 中的顏色:
gl_FragColor = vec4(0.4745, 0.3333, 0.2823, 1);
要讓不同三角形有不同的顏色,要思考的是輸入資料/參數給 fragment shader 的方式,在 fragment shader 中可以使用 uniform,但是那樣的話所有三角形的顏色依然會是一樣,得用類似 attribute / buffer 『每次 shader 呼叫不同』的東西,不過 fragment shader 中是不能使用 attribute 的功能的,回想 Day 2 fragment shader 的運作方式:fragment shader 是每個 pixel 執行一次,不像是 vertex shader 以頂點為單位,取用 array buffer 的方式顯然對不起來,因此需要另外一種傳輸工具 -- varying
varying 這功能可以讓 vertex shader 輸出資料給 fragment shader 使用,但是兩者執行的回合數顯然是對不起來,假設回到一個低解析度三角形的狀況如下圖,vertex shader 執行三次得到三個頂點,灰色的方格每格執行一次 fragment shader 計算顏色:
vertex #1 輸出一組資料、vertex #2 輸出一組資料、vertex #3 輸出一組資料,那麼 fragment #2, fragment #3, fragment #4 這些介於中間 pixel 執行的 fragment shader 會拿到什麼資料?答案是:WebGL 會把頂點與頂點之間輸出的 varying 做平滑化!
假設 vertex #1 輸出 v_number = 0.2
、vertex #2 輸出 v_number = 1.1
,那麼介於 vertex #1, #2 之間的 fragment #2 將拿到兩個點輸出的中間值,並且越接近某個頂點的 pixel 就會得到越接近該頂點輸出的 varying,筆者畫了一張簡易的示意圖舉例 varying 平滑化的樣子:
這個特性不僅解決問題,也讓筆者覺得相當有意思,有種當初玩 flash 移動補間動畫的感覺
我們從 fragment shader 開始修改,varying 宣告方式、使用上跟 attribute 差不多,只是把 attribute
改成 varying
:
precision mediump float;
varying vec3 v_color;
void main() {
gl_FragColor = vec4(v_color, 1);
}
輸出顏色從原本寫死的改用一個叫做 v_color
的 varying vec3
這邊還多了一行
precision mediump float;
,這是用來設定 shader 要使用多精準的浮點數,如果沒有特別需求使用中等mediump
就行了
那麼在 vertex shader 得負責輸出這個值,雖然在 vertex shader 這邊 varying 是要輸出,但是寫法一樣是 varying vec3 v_color;
:
attribute vec2 a_position;
+attribute vec3 a_color;
uniform vec2 u_resolution;
+
+varying vec3 v_color;
void main() {
gl_Position = vec4(
a_position / u_resolution * vec2(2, -2) + vec2(-1, 1),
0, 1
);
+ v_color = a_color;
}
可以看到筆者加了一個 attribute vec3 a_color
,並且直接把 v_color
指定成 a_color
的值,接下來就是重複 Day 3 『畫什麼』的資料輸入、vertex attribute array 等設定:
const colorAttributeLocation = gl.getAttribLocation(program, 'a_color');
// a_position
// ...
// a_color
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.enableVertexAttribArray(colorAttributeLocation);
gl.vertexAttribPointer(
colorAttributeLocation,
3, // size
gl.UNSIGNED_BYTE, // type
true, // normalize
0, // stride
0, // offset
);
gl.bufferData(
gl.ARRAY_BUFFER,
new Uint8Array([
121, 85, 72,
121, 85, 72,
121, 85, 72,
0, 0, 0,
255, 255, 255,
255, 255, 255,
0, 0, 0,
255, 255, 255,
255, 255, 255,
]),
gl.STATIC_DRAW,
);
這段程式碼原理在 Day 3 都有提過,比較需要注意幾點:
gl.vertexAttribPointer()
以及 gl.bufferData()
該行執行的當下要注意 bind 的 ARRAY_BUFFER
是哪個,要不然會對著錯誤的目標做事,當然最好的就是把對於一個 attribute 的操作清楚分好,日後也比較好看出該區域在操作的對象gl.vertexAttribPointer()
的 size: 3
,因為顏色有 3 個 channel: RGB,因此對於每個頂點 gl.bufferData()
要給 3 個值gl.vertexAttribPointer()
使用 gl.UNSIGNED_BYTE
配合 normalize: true
來使用,在 Day 3 有提到: normalize 配合整數型別時可以把資料除以該型別的最大值使 attribute 變成介於 <= 1 的浮點數,那麼在 gl.bufferData()
時傳入 Uint8Array
,並且可以在資料內容寫熟悉的 rgb 值總結來說資料流如下:
a_position
以及顏色資料 a_color
v_color
成為 a_color
v_color
會平滑化,約接近一個頂點的 pixel v_color
就越接近該頂點當初設定的 a_color
v_color
並直接輸出該顏色對於顏色資料的部份,筆者在前三個頂點給一樣的顏色,所以第一個三角形是純色,第二、第三個三角形的第一個頂點為黑色,剩下兩個頂點為白色,因為平滑化的緣故,會得到漸層的效果:
本篇的完整程式碼可以在這邊找到:
筆者認為 WebGL API 最基本的 building block 其實就是 Day 1 到 Day 5 的內容,接下來除了 texture, skybox 之外,幾乎可以說是用這些 building block(搭配線性代數)建構出 3D、光影等效果。下一章就來介紹 texture 並讓 shader 真的開始做一些運算
如果讀者好奇去修改傳入
gl.bufferData()
的資料玩玩的話,應該很快就會發現要自己去對a_position
的第幾組資料跟a_color
的第幾組資料是屬於同一個頂點的,他們在程式碼上有點距離,沒有那種{position: [1,2], color: '#abcdef'}
清楚的感覺,真的要做些應用程式,很快就得自己對這部份做點抽象開始包裝,要不然程式碼一轉眼就會讓人難以摸著頭緒