
鱈魚的 Shader 筆記 - EP01:甚麼是 Shader
大家好,我是鱈魚。(。・∀・)ノ゙
其實大部分的視覺效果或特效用 SVG Filter、Canvas2D API,甚至用 HTML + CSS 也能實現
曾經我也很熱衷使用純 CSS 實現各種視覺效果,所以為甚麼還要學 Shader 呢?
原因很單純,就是早晚會撞到效能天花板。( ˘・з・)
沒辦法強迫大家用超強機器,效能要好只能靠 GPU,而 GPU 只認識 Shader,所以還是只好乖乖來研究 Shader 惹。ԅ( ˘ω˘ԅ)
剛好最近很常做基於 Shader 的效果,想說還是來寫個筆記,以免自己金魚腦,常常忘記。
如果你已經熟悉 JavaScript,那你離 Shader 只差幾個關鍵觀念的距離。
讓我們從 JS 開發者的視角,一步步搞懂 Shader 到底在幹嘛。(•̀ω•́)✧
路人:「JS 不熟,只會框架怎麼辦?(◜௰◝)」
鱈魚:「那就來幫我點廣告,一起熱鬧熱鬧也好 („ಡωಡ„)」
關於 Shader 語言
瀏覽器有兩套圖形 API,各自有不同的 Shader 語言:
- WebGL → GLSL(OpenGL Shading Language)
- WebGPU → WGSL(WebGPU Shading Language)
兩者核心觀念(渲染管線、Vertex/Fragment Shader、Uniform 等)幾乎一樣,只是語法不同。
本文以 WebGL + GLSL 為主,未來有機會再聊聊 WGSL。
GPU 與 CPU 的思維差異
Shader 與一般的 JS 程式有個關鍵差異要注意。( •̀ ω •́ )✧
一般的 JS 程式用的是 CPU 的思考方式是「一步一步,一個一個來」,但 GPU 是「一口氣處理所有像素」。
鱈魚:「和鱈魚和油魚一樣,差一個字差很多 (・∀・)9」
路人:「隨便拉,看起來都很肥 (。-`ω´-)」
鱈魚:「所以我說那個尊重呢?(╥ω╥`)」
甚麼意思哩?可以看看以下示意圖。
左邊 CPU 要一格一格慢慢塗,右邊 GPU 是一口氣全部塗完。( •̀ ω •́ )✧
Shader 是什麼?
瀏覽器畫畫面時,背後有一條「渲染管線」。
Shader 就是管線中那個可以自行程式控制的步驟。
WebGL2 只有兩種 Shader:
| 類型 | 用途 | 執行頻率 |
|---|---|---|
| Vertex Shader | 決定每個頂點的位置 | 每個頂點跑一次 |
| Fragment Shader | 決定每個像素的顏色 | 每個像素跑一次 |
WebGPU / OpenGL 4.x+ / Vulkan / DirectX 還有其他 Shader 類型:
| 類型 | 用途 |
|---|---|
| Geometry Shader | 可以新增或刪除頂點,例如把一個點變成一個三角形 |
| Tessellation Shader | 把粗糙的網格細分成更多三角形,用於地形、曲面等 |
| Compute Shader | 通用 GPU 運算,不一定和圖形渲染有關(物理模擬、粒子系統等) |
| Mesh Shader | 較新的概念,取代 Vertex + Geometry 的組合,效能更好 |
簡單來說,Vertex Shader 圈出哪些像素可以畫,接著讓 Fragment Shader 決定像素要畫甚麼顏色。(*´ω`)人(´ω`*)
Vertex Shader:決定形狀與位置
所以 Vertex Shader 怎麼圈出哪些像素可以畫呢?答案是給他一堆點(頂點)。
路人:「可以撐起地球那種嗎?」
鱈魚:「那是支點 (́⊙◞౪◟⊙‵)」
在 JavaScript 準備好頂點座標後,GPU 會對每個頂點各跑一次 Vertex Shader。
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}a_position 是從 JavaScript 傳進來的頂點座標(Attribute),每個頂點跑一次。
gl_Position 是 GLSL 的內建變數,用來告訴 GPU「這個頂點放在哪」。vec4 的四個值分別是 x、y、z、w,2D 的情況下 z 固定 0.0,w 固定 1.0 就好。
準備好頂點資料後,JavaScript 會呼叫 gl.drawArrays(mode, first, count) 告訴 GPU「開始畫」。
第一個參數 mode 決定 GPU 怎麼連接這些頂點。
常見的繪圖模式有:
點
gl.POINTS:每個頂點畫一個點,大小由 gl_PointSize 決定。
線
gl.LINES:每 2 個頂點連一條線。
三角形
gl.TRIANGLES:每 3 個頂點組一個獨立三角形,最通用。需要 6 個頂點才能拼出四邊形。
本文的互動編輯器用的是 gl.TRIANGLES,用兩個三角形拼出一個覆蓋整個畫面的四邊形:
有了這個全螢幕四邊形,Fragment Shader 就能在畫面上的每個像素執行。
未來討論 Vertex Shader 時,我們再試試看其他不同的繪圖模式。ヾ(◍'౪`◍)ノ゙
Fragment Shader:決定像素顏色
Vertex Shader 決定了頂點圍出的區域後,GPU 會自動把那個區域內的每個像素都交給 Fragment Shader 處理。
不同的繪圖模式,Fragment Shader 執行的範圍也不同:
POINTS:每個頂點周圍gl_PointSize × gl_PointSize的正方形區域LINES:每條線段涵蓋的像素TRIANGLES:三角形內部的所有像素
本文用兩個三角形拼成全螢幕四邊形,所以 Fragment Shader 會在畫面上的每一個像素都跑一次。
每個像素執行時,可以透過 gl_FragCoord.xy 知道「我是畫面上哪一個像素」,然後透過 gl_FragColor 決定最終顏色。
precision mediump float;
void main() {
gl_FragColor = vec4(0.2, 0.6, 1.0, 1.0);
}precision mediump float 是 WebGL 的 Fragment Shader 必須宣告的浮點精度。有三種可以選:
| 精度 | 用途 |
|---|---|
lowp | 省電,但容易出現色階斷層 |
mediump | 夠用於大部分 2D 效果 |
highp | 精準計算距離、座標時必須用 |
GLSL 語法速成
WebGL Shader 使用的語言叫 GLSL(OpenGL Shading Language),語法長得跟 C 很像。
型別對照
| JS / TS | GLSL | 說明 |
|---|---|---|
number | float | 浮點數(GLSL 沒有整數混用,1 要寫成 1.0) |
number | int | 整數 |
boolean | bool | 布林值 |
| — | vec2 | 二維向量 vec2(x, y) |
| — | vec3 | 三維向量 vec3(r, g, b) 或 vec3(x, y, z) |
| — | vec4 | 四維向量 vec4(r, g, b, a) |
向量操作
向量是 GLSL 最強大的特色之一,它可以用一行做到 JS 需要好幾行才能完成的事。
vec3 color = vec3(1.0, 0.5, 0.3);
// Swizzling:隨意取出分量重組
vec2 rg = color.rg; // vec2(1.0, 0.5)
vec3 bgr = color.bgr; // vec3(0.3, 0.5, 1.0)
// 向量運算:整組一起算
vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = vec3(4.0, 5.0, 6.0);
vec3 c = a + b; // vec3(5.0, 7.0, 9.0)
vec3 d = a * 2.0; // vec3(2.0, 4.0, 6.0)同樣的邏輯用 JS 來寫的話:
const color = [1.0, 0.5, 0.3]
// 取出分量要自己 index
const rg = [color[0], color[1]]
const bgr = [color[2], color[1], color[0]]
// 向量運算要用迴圈或 map
const a = [1.0, 2.0, 3.0]
const b = [4.0, 5.0, 6.0]
const c = a.map((value, i) => value + b[i])
const d = a.map((value) => value * 2.0)常用內建函式
| GLSL 函式 | 對應概念 | 說明 |
|---|---|---|
mix(a, b, t) | 線性插值 | a * (1-t) + b * t,t 在 0~1 之間 |
step(edge, x) | 階梯函式 | x < edge ? 0.0 : 1.0 |
smoothstep(a, b, x) | 平滑階梯 | 在 a~b 之間平滑過渡 |
length(v) | 向量長度 | sqrt(v.x² + v.y²) |
clamp(x, min, max) | 限制範圍 | 等同 Math.min(Math.max(x, min), max) |
sin(x) / cos(x) | 三角函式 | 和 JS 的 Math.sin 一樣 |
abs(x) | 絕對值 | 和 JS 的 Math.abs 一樣 |
Fragment Shader:你的第一個像素函式
理論夠多了,讓我們來動手看看吧!(・∀・)9
Fragment Shader 核心概念相當簡單:
每個 pixel 都會問自己兩個問題:
- 我在哪裡? →
gl_FragCoord.xy(像素座標) - 我要什麼顏色? →
gl_FragColor = vec4(r, g, b, a)
就醬!ヾ(◍'౪`◍)ノ゙
整個 Fragment Shader 說穿了就是一個函式:「給定座標,回傳顏色」。
來試試你的第一個 Shader,下方的編輯器可以直接修改程式碼,改完會即時更新畫面。
試著把 vec4(0.2, 0.6, 1.0, 1.0) 裡的數字改成其他值看看。
小提醒:
GLSL 的顏色值範圍是
0.0~1.0,不是 CSS 的0~255。所以vec4(1.0, 0.0, 0.0, 1.0)就是純紅色。vec4的第四個值是 alpha(透明度),但你可能會發現改成0.0畫面完全沒有變化。這是因為 WebGL 預設會將 alpha 預乘進顏色(
premultipliedAlpha),加上沒有開啟透明混合(Blending),alpha 在這裡不會有任何效果。gl_FragColor只能用vec4,不能直接塞vec3進去,不像 JS 會自動轉型。寫
gl_FragColor = vec3(0.2, 0.6, 1.0)會直接編譯錯誤。
座標正規化與 UV
直接用 gl_FragCoord 也可以,不過座標會是實際的像素值(例如 800、600),用起來有幾個痛點:
- 畫布大小一變,效果就跑版(800px 寫好的圓心座標,換成手機 375px 就偏了)
- 很多數學函式(
sin、smoothstep)在 0~1 的範圍內最好用 - 別人的 Shader 範例幾乎都用正規化座標,不轉換就看不懂
所以通常我們會把座標「正規化」到 0.0 ~ 1.0 的範圍,這個正規化後的座標習慣叫做 UV。
vec2 uv = gl_FragCoord.xy / u_resolution;這樣不管畫布多大,uv.x 和 uv.y 都會在 0 到 1 之間,同一段程式碼到處都能用。
來看看用 UV 座標做出漸層效果有多簡單。
把座標值當成顏色值,就自動產生了漸層。(ゝ∀・)b
Uniform:JS 與 Shader 的溝通橋樑
到目前為止,Shader 都只是自己跟自己玩。
實際上需要從 JS 傳資料進去,例如「現在過了幾秒」或「滑鼠在哪裡」。
這時候就要用 Uniform。
uniform 這個字在 GLSL 裡是一個修飾詞,代表「這個值從外部(JS)傳進來,而且每個 pixel 都拿到同樣的值」。
本文的互動編輯器已經幫你注入了三個常用的 uniform:
| Uniform | 型別 | 說明 |
|---|---|---|
u_time | float | 從啟動開始經過的秒數 |
u_resolution | vec2 | 畫布寬高(像素) |
u_mouse | vec2 | 滑鼠在畫布上的位置 |
來試試用 u_time 做動畫效果。
常見圖形
學會了基礎概念,來看看幾個經典的 Shader 圖形怎麼做。
下方的編輯器提供了幾個預設範例,可以切換來看,也可以直接修改試玩。◝( •ω• )◟
畫圓的原理
記得 Shader 繪製都要從像素的視角出發。(「・ω・)「
所以畫圓的重點在於判斷目前的 pixel 距離圓心有多遠。
來逐行拆解上面「圓形」預設範例的程式碼。
第一步:UV 正規化
vec2 uv = gl_FragCoord.xy / u_resolution;把像素座標除以畫布尺寸,得到 0.0 ~ 1.0 的 UV 座標,前面介紹過了。
第二步:把原點移到畫面中心
vec2 center = uv - 0.5;UV 的原點在左下角 (0, 0),減掉 0.5 後原點就移到畫面正中間了。
這時候座標範圍會變成 -0.5 ~ 0.5,中心點是 (0, 0)。
第三步:修正長寬比
不過有個小陷阱,UV 的 x 和 y 都是 0.0 ~ 1.0,但畫布通常不是正方形。
假設畫布是 600×300,那 x 方向的 0.3 實際上代表 180px,y 方向的 0.3 只有 90px。用 length() 算出來的「圓」看起來會變成橢圓。
解法很簡單,把 x 乘上長寬比,讓 x 和 y 在像素層面等比:
center.x *= u_resolution.x / u_resolution.y;以 600×300 的畫布為例,長寬比是 2.0,所以 center.x 會被拉伸成兩倍,讓 x 方向和 y 方向的距離單位一致。
這樣 length() 算出來的距離就會維持等比,圓就是圓了。(•̀ω•́)✧
第四步:計算距離
float dist = length(center);length() 就是畢氏定理(sqrt(x² + y²)),算出目前像素到中心點 (0, 0) 的距離。
第五步:用 step 判斷圓的邊界
float circle = step(dist, 0.3);step(edge, x) 的規則很簡單:x >= edge 回傳 1.0,否則 0.0。
所以 step(dist, 0.3) 就是在問「0.3 有沒有 >= dist」:
- 圓內(dist < 0.3)→
0.3 >= dist成立 →1.0 - 圓外(dist >= 0.3)→
0.3 >= dist不成立 →0.0
也就是圓內得到 1.0,圓外得到 0.0。( •̀ ω •́ )✧
第六步:用 mix 混色
vec3 color = mix(
vec3(0.1, 0.1, 0.2), // 背景色
vec3(0.2, 0.8, 0.6), // 圓的顏色
circle
);mix(a, b, t) 是線性插值,公式是 a * (1 - t) + b * t。
circle = 1.0(圓內)→ 取第二個顏色,藍綠色circle = 0.0(圓外)→ 取第一個顏色,深色背景
第七步:輸出
gl_FragColor = vec4(color, 1.0);把 vec3 的顏色加上 alpha 1.0,組成 vec4 輸出。
你可能會想問:「為甚麼第五到七步不直接用 if-else 就好?比 step + mix 更直覺不是嗎?」(´・ω・`)
if (dist < 0.3) {
gl_FragColor = vec4(0.2, 0.8, 0.6, 1.0);
} else {
gl_FragColor = vec4(0.1, 0.1, 0.2, 1.0);
}的確更直覺沒錯,不過使用 step + mix 避開 if 是為了避免 branch divergence 的問題。
GPU 會把像素分成一小組一小組,同一組裡的像素被強制同步(lockstep),每個時鐘週期都執行同一條指令。
TIP
NVIDIA 叫 warp,32 個一組;AMD 叫 wavefront,64 個
如果遇到 if-else,warp 裡有些像素走 if、有些走 else,但大家被綁在一起沒辦法各走各的,GPU 只好兩條路都跑。
以畫圓為例,假設一個 warp 裡有 20 個像素在圓內、12 個在圓外:
- 先讓 32 個像素全部執行
if分支(塗藍綠色),但圓外那 12 個會被遮罩住,算完後丟棄 - 再讓 32 個像素全部執行
else分支(塗背景色),換圓內那 20 個被遮罩住
等於每個像素都跑了兩條路的指令,花了兩倍時間,step 是純數學運算,不需要分支,GPU 一口氣就算完了。
所以 Shader 裡盡量避免使用 if-else,能用純數學運算就用數學運算。(⌐■_■)✧
理解了每一行之後,來看看完整的畫圓流程圖:
最終效果就是半徑內被塗成藍綠色,半徑外被塗成深色背景,整體看起來就是一個圓。(ゝ∀・)b
挑戰時間
試試看能不能改出這些效果:
- 讓圓形跟著時間移動(提示:圓心加上
sin(u_time)位移) - 畫出同心圓(提示:對 distance 做
sin或fract) - 把硬邊圓改成柔和邊緣(提示:用
smoothstep取代step)
總結 🐟
- Shader 是在 GPU 上執行的小程式,分為 Vertex Shader(處理位置)和 Fragment Shader(處理顏色)
- GPU 的思維是「每個 pixel 同時獨立計算」,不是 CPU 的「一步一步來」
- 透過 Uniform 可以從 JS 傳入時間、滑鼠位置等動態資料
推薦學習資源
- The Book of Shaders:最經典的 Shader 互動教學
- Shadertoy:Shader 界的 CodePen,大量範例可以拆來學
以上就是從 JS 開發者角度的 Shader 入門,有錯誤還請多多指教。
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。(*´∀`)~♥