
鱈魚的 Shader 筆記 - EP02:認識 Vertex Shader
大家好,我是鱈魚。(。・∀・)ノ゙
上一篇我們認識了 Fragment Shader,學會讓每個像素自己決定顏色。
不過當時的頂點都是固定的全螢幕四邊形,Vertex Shader 只負責把座標原封不動丟出去,毫無存在感。(´ー`)
現在讓我們來認識 Vertex Shader,學會怎麼控制每個頂點的位置、大小和顏色。
先來看看最終成品:
畫面上每個點的位置、大小、顏色全都不一樣,這些全靠 Vertex Shader 搞定。
要做到這個效果,我們需要搞懂四個核心概念:
- Attribute:讓每個頂點擁有專屬資料
- 繪圖模式:決定頂點要連成什麼形狀
- gl_PointSize:控制點的大小
- Varying:讓資料從 Vertex Shader 傳到 Fragment Shader
讓我們一關一關來。(•̀ω•́)✧
第一關:Attribute
為什麼需要 Attribute?
EP01 介紹過 Uniform,它從 JS 傳資料到 Shader,特色是每個頂點拿到的值都一樣。
但如果要讓每個頂點出現在不同位置呢?用 Uniform 做不到,因為所有頂點都會拿到同一組座標,全部擠在一起。
這時候就需要 Attribute。
| 特性 | Uniform | Attribute |
|---|---|---|
| 資料來源 | JS 傳入 | JS 傳入 |
| 每個頂點的值 | 全部一樣 | 各自不同 |
| 宣告方式 | uniform vec2 u_xxx | attribute vec2 a_xxx |
| 常見用途 | 時間、滑鼠位置、畫布尺寸 | 頂點座標、顏色、大小 |
簡單說,Uniform 是廣播,Attribute 是點名發資料。(・∀・)9
JS 端怎麼傳 Attribute?
Attribute 資料存在 JS 這邊,但 Shader 跑在 GPU 上。兩邊記憶體不共通,所以不能直接丟變數過去。
想像你要寄包裹給 GPU,流程大概是這樣:
- 準備箱子(建立 Buffer)
- 把箱子放上輸送帶(綁定到 ARRAY_BUFFER)
- 往箱子裡塞資料(bufferData)
- 在箱子上貼標籤,告訴 GPU「裡面是什麼、怎麼讀」(vertexAttribPointer)
一步一步來。( •̀ ω •́ )✧
Step 1、2、3:建立 Buffer 並塞資料
// 準備箱子
const buffer = gl.createBuffer()
// 放上輸送帶
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
// 塞入 3 個頂點的座標(每個頂點 2 個 float:x, y)
const positionList = new Float32Array([
-1.0, // 頂點 1 左下(x)
-1.0, // 頂點 1 左下(y)
1.0, // 頂點 2 右下(x)
-1.0, // 頂點 2 右下(y)
-1.0, // 頂點 3 左上(x)
1.0, // 頂點 3 左上(y)
])
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)Float32Array 裡的數字是攤平的一維陣列,每 2 個一組就是一個頂點的 (x, y)。
試著新增或修改下方的頂點資料,觀察右邊攤平後的陣列怎麼對應:
gl.STATIC_DRAW 用於提示 GPU 這筆資料不會常常更新,方便做快取最佳化。如果會頻繁更新就用 gl.DYNAMIC_DRAW,不過用錯也不會壞,只是效能差一點。
Step 4:貼標籤,讓 GPU 知道怎麼讀
資料塞進去了,但 GPU 還不知道這堆數字代表什麼。這一步就是告訴它:「這個 Buffer 對應 Shader 裡的 a_position,每次讀 2 個 float 當一組」。
// 找到 Shader 裡 a_position 的位置
const location = gl.getAttribLocation(program, 'a_position')
// 啟用這個 attribute
gl.enableVertexAttribArray(location)
// 告訴 GPU 怎麼讀:每個頂點讀 2 個 float
gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0)vertexAttribPointer 的第二個參數 2 代表「每個頂點讀 2 個 float」,對應 Shader 裡的 vec2。如果是 vec3 就填 3,float 就填 1。
整理一下兩邊的對應關係:
| JS 端 | Shader 端 | 說明 |
|---|---|---|
Float32Array([-1, -1, 1, -1, ...]) | attribute vec2 a_position | 資料內容 ↔ 變數宣告 |
getAttribLocation(program, 'a_position') | a_position | 透過名字找到彼此 |
vertexAttribPointer(loc, 2, ...) | vec2 | 每個頂點讀幾個 float |
簡單說,JS 負責「準備資料、說明格式」,Shader 負責「接收並使用」。兩邊靠 attribute 的名字(如 a_position)連結在一起。(ゝ∀・)b
整個流程一圖總結:
gl.createBuffer()gl.bindBuffer(gl.ARRAY_BUFFER, buffer)gl.bufferData(..., positionList, ...)gl.vertexAttribPointer(loc, 2, ...)attribute vec2 a_positiona_position 就能收到每個頂點的座標了 gl_Position 的 z 和 w
EP01 提過 gl_Position = vec4(x, y, z, w),當時 z 和 w 都直接帶常數,現在來搞懂它們各自在幹嘛。
z 控制深度
z 決定「誰在前面、誰被擋住」。GPU 用 z 值做深度測試(depth test),z 越小越靠近鏡頭。2D 場景不需要遮擋,所以固定 0.0。
w 控制透視縮放
vec4(x, y, z, w) 其實是數學上的齊次座標(homogeneous coordinates),多出來的 w 讓平移、旋轉、縮放、投影都能用同一套矩陣乘法搞定。
GPU 會在最後一步把 x、y、z 全部除以 w,這步叫做透視除法(perspective division)。
// GPU 內部做的事
螢幕座標 = (x/w, y/w, z/w)做 3D 透視投影時,投影矩陣會把「離鏡頭的距離」塞進 w。離越遠 → w 越大 → 除完後座標越小 → 物體看起來越小,自然產生近大遠小的效果。
2D 不需要透視,所以 w 固定 1.0(除以 1 嘛,就是除了個寂寞 (´・ω・`))。
總結來說 z 負責排序,w 負責縮放。齊次座標和投影矩陣的推導是一整塊大主題,之後有機會再開一篇來聊 (´∀`)
動手試試:Attribute
下方的範例使用 gl.POINTS 模式繪製頂點。
可以試試看:
- 新增或修改頂點資料
- Vertex Shader:微調位置與尺寸。
- Fragment Shader:修改顏色。
第二關:繪圖模式
頂點怎麼連成形狀?
有了頂點座標,接下來 GPU 要決定「這些點怎麼連」。
這由 gl.drawArrays(mode, first, count) 的第一個參數 mode 決定。EP01 提過 POINTS、LINES、TRIANGLES,這裡完整介紹所有模式。
點
gl.POINTS:每個頂點畫一個點。
線
| 模式 | 規則 | 說明 |
|---|---|---|
LINES | 每 2 個一組 | 1-2 一條、3-4 一條,奇數個會忽略最後一個 |
LINE_STRIP | 前後相連 | 1-2、2-3、3-4... 像折線圖 |
LINE_LOOP | 前後相連 + 首尾相接 | 和 LINE_STRIP 一樣,最後再從尾連回頭 |
三角形
| 模式 | 規則 | 說明 |
|---|---|---|
TRIANGLES | 每 3 個一組 | 1-2-3 一個、4-5-6 一個,不共用頂點 |
TRIANGLE_STRIP | 前後共用 | 1-2-3、2-3-4、3-4-5... 每個新頂點和前兩個組成三角形 |
TRIANGLE_FAN | 扇形展開 | 1-2-3、1-3-4、1-4-5... 第一個頂點是共用的中心 |
動手試試:繪圖模式
下方用同一組五邊形頂點,切換不同繪圖模式看看實際效果。
切換模式前可以先仔細想想,猜猜看會跑出什麼圖案。੭ ˙ᗜ˙ )੭
點不夠會怎麼樣?
頂點數不能整除時,多餘的頂點會被忽略,目前只有 5 個頂點,所以:
LINES模式只能配成 2 條線(4 個頂點),TRIANGLES模式只能組成 1 個三角形(3 個頂點)。
是不是很有趣啊!ԅ(´∀` ԅ)
第三關:gl_PointSize
控制點的大小
gl.POINTS 模式下,每個點預設只有 1 像素,幾乎看不到。
在 Vertex Shader 裡可以透過內建變數 gl_PointSize 設定點的大小(單位是像素):
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
gl_PointSize = 20.0; // 每個點 20px
}不過這樣每個點都一樣大,有點無聊。
如果想讓每個點大小不同,只要再加一個 Attribute 就好:
attribute vec2 a_position;
attribute float a_size; // 每個頂點專屬的大小
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
gl_PointSize = a_size; // 用 Attribute 的值
}JS 端再多開一個 Buffer 塞大小資料:
const sizeList = new Float32Array([20.0, 35.0, 15.0, 45.0, 28.0])
// 建立 Buffer、綁定、塞資料、設定 vertexAttribPointer...
// 流程和 a_position 一樣,只是 size 參數改成 1(每個頂點讀 1 個 float)概念相同,意思是如果以後需要追加顏色、透明度,只要 Buffer 開好開滿,想傳幾組就傳幾組!ᕕ( ゚ ∀。)ᕗ
不過 Shader 裡能同時使用的 attribute 數量有上限,WebGL 保證至少 16 個,一般夠用了 (ゝ∀・)b
gl_PointCoord:點內部的座標
gl.POINTS 模式有個限定的 Fragment Shader 內建變數:gl_PointCoord。
gl.POINTS 模式下,每個點在螢幕上佔一個正方形區域(大小由 gl_PointSize 決定)。gl_PointCoord 就是這個正方形內部的相對座標,範圍 0.0 ~ 1.0,左上角 (0, 0),右下角 (1, 1)。
注意 gl_PointCoord 和 JS 傳入的頂點座標(a_position)完全無關。
a_position 決定點畫在畫布哪裡,gl_PointCoord 則是點內部每個像素的座標。
利用 gl_PointCoord 可以在方形的點裡畫出圓形:
void main() {
vec2 center = gl_PointCoord - 0.5;
float dist = length(center);
// 丟棄圓外的像素
if (dist > 0.5) discard;
gl_FragColor = vec4(0.2, 0.8, 0.6, 1.0);
}gl_PointCoord - 0.5 把座標原點移到正方形中心,length() 算出離中心的距離。
超過 0.5 就在圓外,用 discard 丟棄。
discard 是 GLSL 的關鍵字,作用是「這個 fragment 不畫了,蛋雕!੭ ˙ᗜ˙ )੭」。
Fragment 和 Pixel 不一樣喔
嚴格來說,Fragment Shader 處理的是 fragment,不是 pixel。
fragment 是「可能成為 pixel 的候選人」,還要通過深度測試、alpha 測試等關卡才能變成最終螢幕上的 pixel。
就像面試過了不代表拿到 offer,中間還有好幾關 (´・ω・`)
不過入門階段暫時把 fragment 想成像素也沒問題,之後接觸深度測試、stencil 測試時自然會更清楚兩者的差異。
動手試試:gl_PointSize
下方範例用了 a_position 和 a_size 兩個 Attribute,搭配 gl_PointCoord 畫出柔和邊緣的圓點。
重點在 Fragment Shader 這行:
float alpha = 1.0 - smoothstep(0.35, 0.5, dist);smoothstep(edge0, edge1, x) 會在 edge0 到 edge1 之間產生平滑的 0→1 過渡,所以:
0.35(edge0):dist 小於此值時,smoothstep回傳0,alpha 為1.0,完全不透明0.5(edge1):dist 大於此值時,smoothstep回傳1,alpha 為0.0,完全透明- 兩者之間就是柔和漸變的區間
間距越大,邊緣越朦朧;兩個值越接近,邊緣越銳利。
- 把
0.35改成0.1,漸層範圍變大,圓點像水母一樣軟趴趴 - 改成
0.49,幾乎沒有漸層,邊緣硬得像餅乾 (´∀`)
不過光在 Shader 裡設定 alpha 還不夠,WebGL 預設不處理透明度,需要在 JS 端手動開啟 Alpha Blending:
// 這段寫在 JS 的繪製流程中,通常放在 gl.drawArrays() 之前
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)blendFunc 設定混合公式,SRC_ALPHA 和 ONE_MINUS_SRC_ALPHA 就是最常見的「正常透明度」效果。
如果沒開,Shader 算出的 alpha 會完全沒有效果,寫了個寂寞 (´;ω;`)
第四關:Varying
從 Vertex 到 Fragment 的橋樑
到目前為止,Fragment Shader 的顏色都直接寫死在程式碼裡,如何讓每個頂點有不同顏色呢?
顏色是 Fragment Shader 的工作,但「每個頂點不同」的資料只有 Attribute 能做到,而 Attribute 只能在 Vertex Shader 裡使用。
怎麼辦?用 Varying 當作中間人。(•̀ω•́)✧
// ── Vertex Shader ──
attribute vec2 a_position;
attribute vec3 a_color;
varying vec3 v_color; // 宣告 varying
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_color = a_color; // 把顏色交給 varying
}// ── Fragment Shader ──
precision mediump float;
varying vec3 v_color; // 同名的 varying
void main() {
gl_FragColor = vec4(v_color, 1.0);
}兩邊都宣告同名同型別的 varying,就可以 Vertex Shader 寫入,Fragment Shader 讀取。
簡單來說就是 JS 傳給 Vertex Shader,Vertex Shader 再傳給 Fragment Shader,接力賽的概念 ( ‧ω‧)ノ╰(‧ω‧ )
GPU 自動插值
Varying 最神奇的地方在於 GPU 會自動做插值。
假設三角形的三個頂點分別設定紅、綠、藍三種顏色,三角形內部的每個 fragment 會根據自己和三個頂點的距離,自動混合出過渡色。
不需要寫任何混色的程式碼,GPU 的 rasterizer 會自動算出對應比例,這是 fixed function(固定管線功能),不能用 Shader 改寫。
這就是為什麼 OpenGL 入門教學常常以「彩色三角形」當作 Hello World。(ゝ∀・)b
其實不是單純的線性插值
預設的插值模式是透視校正插值(perspective-correct interpolation),會把頂點的 w 分量納入計算,確保 3D 透視場景下的插值結果看起來正確。
我們目前 w 都是 1.0,所以效果和線性插值完全一樣,未來有機會再來詳細比較 (´∀`)
動手試試:Varying
經典的彩色三角形,三個頂點分別是紅、綠、藍。
試試以下改法:
- 切到 Vertex Shader,把
v_color = a_color改成v_color = vec3(1.0, 1.0, 0.0),三個頂點都傳同一色,整個三角形變成純黃色,插值無事可做 - 改成
v_color = vec3(a_position, 0.0),把座標當顏色用,左下偏黑、右下偏紅、上方偏綠,座標值直接反映在色彩上 (°ω°)ノ - 切到 Fragment Shader,把
gl_FragColor = vec4(v_color, 1.0)改成gl_FragColor = vec4(1.0 - v_color, 1.0),顏色反轉,原本的紅變成青、綠變成洋紅
組合技:回到成品
學完四關,再回頭看看開頭的成品。切到 Vertex Shader 分頁,逐行解讀:
attribute vec2 a_position; // Attribute:每個頂點的座標
attribute float a_size; // Attribute:每個頂點的大小
attribute vec3 a_color; // Attribute:每個頂點的顏色
varying vec3 v_color; // Varying:傳給 Fragment Shader
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
gl_PointSize = a_size; // 控制點的大小
v_color = a_color; // 把顏色交出去
}Fragment Shader 負責用 gl_PointCoord 把方形的點畫成圓形,再從 v_color 取得顏色:
precision mediump float;
varying vec3 v_color;
void main() {
vec2 center = gl_PointCoord - 0.5;
float dist = length(center);
if (dist > 0.5) discard;
float alpha = 1.0 - smoothstep(0.3, 0.5, dist);
gl_FragColor = vec4(v_color, alpha);
}挑戰時間
試試看能不能改出這些效果:
- 讓點的位置隨
u_time移動(提示:gl_Position.x += sin(u_time)之類的) - 讓點的大小隨時間脈動(提示:
gl_PointSize = a_size + sin(u_time) * 10.0) - 讓顏色隨時間變化(提示:在 Fragment Shader 裡對
v_color加上sin(u_time)的偏移)
總結 🐟
- Attribute讓每個頂點擁有專屬資料,和 Uniform 的「全部一樣」互補
- 繪圖模式決定 GPU 怎麼連接頂點:POINTS、LINES、TRIANGLES 等,90% 的時候用 TRIANGLES 就好
- gl_PointSize在 POINTS 模式下控制點的大小,搭配
gl_PointCoord可以在點內部畫圓 - Varying是 Vertex Shader 傳資料給 Fragment Shader 的橋樑,GPU 會自動做線性插值
下一篇我們會用這些概念組合出真正的粒子系統,讓大量的點動起來,產生煙霧飄散的效果。敬請期待!(ノ>ω<)ノ
以上就是 Vertex Shader 核心概念的介紹,有錯誤還請多多指教。
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。(*´∀`)~♥