Skip to content

shader-notes

鱈魚的 Shader 筆記 - EP02:認識 Vertex Shader

大家好,我是鱈魚。(。・∀・)ノ゙

上一篇我們認識了 Fragment Shader,學會讓每個像素自己決定顏色。

不過當時的頂點都是固定的全螢幕四邊形,Vertex Shader 只負責把座標原封不動丟出去,毫無存在感。(´ー`)

現在讓我們來認識 Vertex Shader,學會怎麼控制每個頂點的位置、大小和顏色。

先來看看最終成品:

畫面上每個點的位置、大小、顏色全都不一樣,這些全靠 Vertex Shader 搞定。

要做到這個效果,我們需要搞懂四個核心概念:

  1. Attribute:讓每個頂點擁有專屬資料
  2. 繪圖模式:決定頂點要連成什麼形狀
  3. gl_PointSize:控制點的大小
  4. Varying:讓資料從 Vertex Shader 傳到 Fragment Shader

讓我們一關一關來。(•̀ω•́)✧

第一關:Attribute

為什麼需要 Attribute?

EP01 介紹過 Uniform,它從 JS 傳資料到 Shader,特色是每個頂點拿到的值都一樣

但如果要讓每個頂點出現在不同位置呢?用 Uniform 做不到,因為所有頂點都會拿到同一組座標,全部擠在一起。

這時候就需要 Attribute

Uniform
所有頂點拿到同一份資料
u_color 同色 同色 同色
Attribute
每個頂點拿到專屬的資料
紅色 綠色 藍色 各自 各自 各自
特性UniformAttribute
資料來源JS 傳入JS 傳入
每個頂點的值全部一樣各自不同
宣告方式uniform vec2 u_xxxattribute vec2 a_xxx
常見用途時間、滑鼠位置、畫布尺寸頂點座標、顏色、大小

簡單說,Uniform 是廣播,Attribute 是點名發資料。(・∀・)9

JS 端怎麼傳 Attribute?

Attribute 資料存在 JS 這邊,但 Shader 跑在 GPU 上。兩邊記憶體不共通,所以不能直接丟變數過去。

想像你要寄包裹給 GPU,流程大概是這樣:

  1. 準備箱子(建立 Buffer)
  2. 把箱子放上輸送帶(綁定到 ARRAY_BUFFER)
  3. 往箱子裡塞資料(bufferData)
  4. 在箱子上貼標籤,告訴 GPU「裡面是什麼、怎麼讀」(vertexAttribPointer)

一步一步來。( •̀ ω •́ )✧

Step 1、2、3:建立 Buffer 並塞資料

js
// 準備箱子
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)

試著新增或修改下方的頂點資料,觀察右邊攤平後的陣列怎麼對應:

INPUT頂點資料
頂點 1
頂點 2
頂點 3
OUTPUTFloat32Array(6)
0-1.0 ← 頂點1.x
1-1.0 ← 頂點1.y
21.0 ← 頂點2.x
3-1.0 ← 頂點2.y
4-1.0 ← 頂點3.x
51.0 ← 頂點3.y

gl.STATIC_DRAW 用於提示 GPU 這筆資料不會常常更新,方便做快取最佳化。如果會頻繁更新就用 gl.DYNAMIC_DRAW,不過用錯也不會壞,只是效能差一點。

Step 4:貼標籤,讓 GPU 知道怎麼讀

資料塞進去了,但 GPU 還不知道這堆數字代表什麼。這一步就是告訴它:「這個 Buffer 對應 Shader 裡的 a_position,每次讀 2 個 float 當一組」。

js
// 找到 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 就填 3float 就填 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

整個流程一圖總結:

1gl.createBuffer()
Buffer空的
在 GPU 記憶體開一塊空間
2gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
ARRAY_BUFFER
Buffer
把 Buffer 綁到操作插槽上
3gl.bufferData(..., positionList, ...)
JS
-1-11-1-11
Buffer
把 Float32Array 資料複製進 Buffer
4gl.vertexAttribPointer(loc, 2, ...)
-1-1
1-1
-11
頂點 1頂點 2頂點 3
告訴 GPU 每 2 個 float 讀成一組 → 對應 attribute vec2 a_position
Vertex Shader 裡的 a_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)。

glsl
// GPU 內部做的事
螢幕座標 = (x/w, y/w, z/w)

做 3D 透視投影時,投影矩陣會把「離鏡頭的距離」塞進 w。離越遠 → w 越大 → 除完後座標越小 → 物體看起來越小,自然產生近大遠小的效果。

2D 不需要透視,所以 w 固定 1.0(除以 1 嘛,就是除了個寂寞 (´・ω・`))。

總結來說 z 負責排序,w 負責縮放。齊次座標和投影矩陣的推導是一整塊大主題,之後有機會再開一篇來聊 (´∀`)

動手試試:Attribute

下方的範例使用 gl.POINTS 模式繪製頂點。

可以試試看:

  • 新增或修改頂點資料
  • Vertex Shader:微調位置與尺寸。
  • Fragment Shader:修改顏色。
#1
#2
#3
#4
#5

第二關:繪圖模式

頂點怎麼連成形狀?

有了頂點座標,接下來 GPU 要決定「這些點怎麼連」。

這由 gl.drawArrays(mode, first, count) 的第一個參數 mode 決定。EP01 提過 POINTSLINESTRIANGLES,這裡完整介紹所有模式。

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 設定點的大小(單位是像素):

glsl
void main() {
  gl_Position = vec4(a_position, 0.0, 1.0);
  gl_PointSize = 20.0;  // 每個點 20px
}

不過這樣每個點都一樣大,有點無聊。

如果想讓每個點大小不同,只要再加一個 Attribute 就好:

glsl
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 塞大小資料:

js
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_PointSize(0,0)(1,0)(0,1)(1,1)gl_PointCoord每個像素都有自己的座標

注意 gl_PointCoord 和 JS 傳入的頂點座標(a_position)完全無關。

a_position 決定點畫在畫布哪裡,gl_PointCoord 則是點內部每個像素的座標。

利用 gl_PointCoord 可以在方形的點裡畫出圓形:

glsl
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 丟棄。

dist = 0.5(0.5, 0.5)保留discard

discard 是 GLSL 的關鍵字,作用是「這個 fragment 不畫了,蛋雕!੭ ˙ᗜ˙ )੭」。

Fragment 和 Pixel 不一樣喔

嚴格來說,Fragment Shader 處理的是 fragment,不是 pixel。

fragment 是「可能成為 pixel 的候選人」,還要通過深度測試、alpha 測試等關卡才能變成最終螢幕上的 pixel。

就像面試過了不代表拿到 offer,中間還有好幾關 (´・ω・`)

不過入門階段暫時把 fragment 想成像素也沒問題,之後接觸深度測試、stencil 測試時自然會更清楚兩者的差異。

動手試試:gl_PointSize

下方範例用了 a_positiona_size 兩個 Attribute,搭配 gl_PointCoord 畫出柔和邊緣的圓點。

重點在 Fragment Shader 這行:

glsl
float alpha = 1.0 - smoothstep(0.35, 0.5, dist);

smoothstep(edge0, edge1, x) 會在 edge0edge1 之間產生平滑的 0→1 過渡,所以:

  • 0.35(edge0):dist 小於此值時,smoothstep 回傳 0,alpha 為 1.0,完全不透明
  • 0.5(edge1):dist 大於此值時,smoothstep 回傳 1,alpha 為 0.0,完全透明
  • 兩者之間就是柔和漸變的區間
distalpha01.00.350.5不透明漸變透明 alpha = 1.0 - smoothstep(0.35, 0.5, dist)

間距越大,邊緣越朦朧;兩個值越接近,邊緣越銳利。

  • 0.35 改成 0.1,漸層範圍變大,圓點像水母一樣軟趴趴
  • 改成 0.49,幾乎沒有漸層,邊緣硬得像餅乾 (´∀`)

不過光在 Shader 裡設定 alpha 還不夠,WebGL 預設不處理透明度,需要在 JS 端手動開啟 Alpha Blending

js
// 這段寫在 JS 的繪製流程中,通常放在 gl.drawArrays() 之前
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)

blendFunc 設定混合公式,SRC_ALPHAONE_MINUS_SRC_ALPHA 就是最常見的「正常透明度」效果。

如果沒開,Shader 算出的 alpha 會完全沒有效果,寫了個寂寞 (´;ω;`)

#1
position
size
#2
position
size
#3
position
size
#4
position
size
#5
position
size

第四關:Varying

從 Vertex 到 Fragment 的橋樑

到目前為止,Fragment Shader 的顏色都直接寫死在程式碼裡,如何讓每個頂點有不同顏色呢?

顏色是 Fragment Shader 的工作,但「每個頂點不同」的資料只有 Attribute 能做到,而 Attribute 只能在 Vertex Shader 裡使用。

怎麼辦?用 Varying 當作中間人。(•̀ω•́)✧

glsl
// ── 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
}
glsl
// ── 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 會根據自己和三個頂點的距離,自動混合出過渡色。

Vertex Shader
A B C
v_color = a_color
GPU 自動插值
Fragment Shader
gl_FragColor = vec4(v_color, 1.0)

不需要寫任何混色的程式碼,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),顏色反轉,原本的紅變成青、綠變成洋紅
#1
position
color
#2
position
color
#3
position
color

組合技:回到成品

學完四關,再回頭看看開頭的成品。切到 Vertex Shader 分頁,逐行解讀:

glsl
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 取得顏色:

glsl
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);
}
#1
position
size
color
#2
position
size
color
#3
position
size
color
#4
position
size
color
#5
position
size
color
#6
position
size
color
#7
position
size
color
#8
position
size
color
#9
position
size
color
#10
position
size
color
#11
position
size
color
#12
position
size
color

挑戰時間

試試看能不能改出這些效果:

  1. 讓點的位置隨 u_time 移動(提示:gl_Position.x += sin(u_time) 之類的)
  2. 讓點的大小隨時間脈動(提示:gl_PointSize = a_size + sin(u_time) * 10.0
  3. 讓顏色隨時間變化(提示:在 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 核心概念的介紹,有錯誤還請多多指教。

感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。(*´∀`)~♥