Skip to content

shader-notes

鱈魚的 Shader 筆記 - EP01:甚麼是 Shader

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

其實大部分的視覺效果或特效用 SVG Filter、Canvas2D API,甚至用 HTML + CSS 也能實現

曾經我也很熱衷使用純 CSS 實現各種視覺效果,所以為甚麼還要學 Shader 呢?

原因很單純,就是早晚會撞到效能天花板。( ˘・з・)

沒辦法強迫大家用超強機器,效能要好只能靠 GPU,而 GPU 只認識 Shader,所以還是只好乖乖來研究 Shader 惹。ԅ( ˘ω˘ԅ)

剛好最近很常做基於 Shader 的效果,想說還是來寫個筆記,以免自己金魚腦,常常忘記。

如果你已經熟悉 JavaScript,那你離 Shader 只差幾個關鍵觀念的距離。

讓我們從 JS 開發者的視角,一步步搞懂 Shader 到底在幹嘛。(•̀ω•́)✧


路人:「JS 不熟,只會框架怎麼辦?(◜௰◝)」

鱈魚:「那就來幫我點廣告,一起熱鬧熱鬧也好 („ಡωಡ„)」


關於 Shader 語言

瀏覽器有兩套圖形 API,各自有不同的 Shader 語言:

  • WebGLGLSL(OpenGL Shading Language)
  • WebGPUWGSL(WebGPU Shading Language)

兩者核心觀念(渲染管線、Vertex/Fragment Shader、Uniform 等)幾乎一樣,只是語法不同。

本文以 WebGL + GLSL 為主,未來有機會再聊聊 WGSL。

GPU 與 CPU 的思維差異

Shader 與一般的 JS 程式有個關鍵差異要注意。( •̀ ω •́ )✧

一般的 JS 程式用的是 CPU 的思考方式是「一步一步,一個一個來」,但 GPU 是「一口氣處理所有像素」。


鱈魚:「和鱈魚和油魚一樣,差一個字差很多 (・∀・)9」

路人:「隨便拉,看起來都很肥 (。-`ω´-)」

鱈魚:「所以我說那個尊重呢?(╥ω╥`)」


甚麼意思哩?可以看看以下示意圖。

CPU逐一處理
GPU全部同時

左邊 CPU 要一格一格慢慢塗,右邊 GPU 是一口氣全部塗完。( •̀ ω •́ )✧

Shader 是什麼?

瀏覽器畫畫面時,背後有一條「渲染管線」。

Shader 就是管線中那個可以自行程式控制的步驟。

JS 準備資料 頂點座標、顏色、Uniform Vertex Shader 決定形狀位置 光柵化 GPU 自動處理 Fragment 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。

glsl
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 決定。

1234

gl.LINES:每 2 個頂點連一條線。

1234

三角形

gl.TRIANGLES:每 3 個頂點組一個獨立三角形,最通用。需要 6 個頂點才能拼出四邊形。

123456

本文的互動編輯器用的是 gl.TRIANGLES,用兩個三角形拼出一個覆蓋整個畫面的四邊形:

(-1,1)(1,1)(-1,-1)(1,-1)△1△2

有了這個全螢幕四邊形,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 決定最終顏色。

glsl
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 / TSGLSL說明
numberfloat浮點數(GLSL 沒有整數混用,1 要寫成 1.0
numberint整數
booleanbool布林值
vec2二維向量 vec2(x, y)
vec3三維向量 vec3(r, g, b)vec3(x, y, z)
vec4四維向量 vec4(r, g, b, a)

向量操作

向量是 GLSL 最強大的特色之一,它可以用一行做到 JS 需要好幾行才能完成的事。

glsl
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 來寫的話:

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 * tt 在 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 核心概念相當簡單:

gl_FragCoord 我在哪裡? 你的計算邏輯 gl_FragColor 我要什麼顏色

每個 pixel 都會問自己兩個問題:

  1. 我在哪裡?gl_FragCoord.xy(像素座標)
  2. 我要什麼顏色?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 就偏了)
  • 很多數學函式(sinsmoothstep)在 0~1 的範圍內最好用
  • 別人的 Shader 範例幾乎都用正規化座標,不轉換就看不懂

所以通常我們會把座標「正規化」到 0.0 ~ 1.0 的範圍,這個正規化後的座標習慣叫做 UV

gl_FragCoord.xy (0, 0) ~ (800, 600) ÷ u_resolution uv (0.0, 0.0) ~ (1.0, 1.0)
glsl
vec2 uv = gl_FragCoord.xy / u_resolution;

這樣不管畫布多大,uv.xuv.y 都會在 0 到 1 之間,同一段程式碼到處都能用。

來看看用 UV 座標做出漸層效果有多簡單。

把座標值當成顏色值,就自動產生了漸層。(ゝ∀・)b

Uniform:JS 與 Shader 的溝通橋樑

到目前為止,Shader 都只是自己跟自己玩。

實際上需要從 JS 傳資料進去,例如「現在過了幾秒」或「滑鼠在哪裡」。

這時候就要用 Uniform

JavaScript gl.uniform1f(loc, time) gl.uniform2f(loc, x, y) GLSL uniform float u_time; uniform vec2 u_mouse; uniform

uniform 這個字在 GLSL 裡是一個修飾詞,代表「這個值從外部(JS)傳進來,而且每個 pixel 都拿到同樣的值」。

本文的互動編輯器已經幫你注入了三個常用的 uniform:

Uniform型別說明
u_timefloat從啟動開始經過的秒數
u_resolutionvec2畫布寬高(像素)
u_mousevec2滑鼠在畫布上的位置

來試試用 u_time 做動畫效果。

常見圖形

學會了基礎概念,來看看幾個經典的 Shader 圖形怎麼做。

下方的編輯器提供了幾個預設範例,可以切換來看,也可以直接修改試玩。◝( •ω• )◟

畫圓的原理

記得 Shader 繪製都要從像素的視角出發。(「・ω・)「

所以畫圓的重點在於判斷目前的 pixel 距離圓心有多遠。

來逐行拆解上面「圓形」預設範例的程式碼。

第一步:UV 正規化

glsl
vec2 uv = gl_FragCoord.xy / u_resolution;

把像素座標除以畫布尺寸,得到 0.0 ~ 1.0 的 UV 座標,前面介紹過了。

第二步:把原點移到畫面中心

glsl
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() 算出來的「圓」看起來會變成橢圓。

問題:UV 座標的陷阱 x: 0→1 對應 600px y: 0→1 對應 300px 同樣是 0.3 的距離 x 方向 = 180px,y 方向 = 90px 導致圓變成橢圓! 解法:x 乘上長寬比 center.x *= u_resolution.x / u_resolution.y

解法很簡單,把 x 乘上長寬比,讓 x 和 y 在像素層面等比:

glsl
center.x *= u_resolution.x / u_resolution.y;

以 600×300 的畫布為例,長寬比是 2.0,所以 center.x 會被拉伸成兩倍,讓 x 方向和 y 方向的距離單位一致。

這樣 length() 算出來的距離就會維持等比,圓就是圓了。(•̀ω•́)✧

第四步:計算距離

glsl
float dist = length(center);

length() 就是畢氏定理(sqrt(x² + y²)),算出目前像素到中心點 (0, 0) 的距離。

第五步:用 step 判斷圓的邊界

glsl
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 混色

glsl
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(圓外)→ 取第一個顏色,深色背景

第七步:輸出

glsl
gl_FragColor = vec4(color, 1.0);

vec3 的顏色加上 alpha 1.0,組成 vec4 輸出。


你可能會想問:「為甚麼第五到七步不直接用 if-else 就好?比 step + mix 更直覺不是嗎?」(´・ω・`)

glsl
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 個在圓外:

  1. 先讓 32 個像素全部執行 if 分支(塗藍綠色),但圓外那 12 個會被遮罩住,算完後丟棄
  2. 再讓 32 個像素全部執行 else 分支(塗背景色),換圓內那 20 個被遮罩住

等於每個像素都跑了兩條路的指令,花了兩倍時間,step 是純數學運算,不需要分支,GPU 一口氣就算完了。

所以 Shader 裡盡量避免使用 if-else,能用純數學運算就用數學運算。(⌐■_■)✧


理解了每一行之後,來看看完整的畫圓流程圖:

修正長寬比 center.x *= aspect 計算到中心的距離 dist = length(center) step 判斷邊界 circle = step(dist, 0.3) 圓內 → 1.0  圓外 → 0.0 mix 混合顏色 mix(背景色, 圓色, circle) 輸出顏色 gl_FragColor = vec4(color, 1.0)

最終效果就是半徑內被塗成藍綠色,半徑外被塗成深色背景,整體看起來就是一個圓。(ゝ∀・)b

挑戰時間

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

  1. 讓圓形跟著時間移動(提示:圓心加上 sin(u_time) 位移)
  2. 畫出同心圓(提示:對 distance 做 sinfract
  3. 把硬邊圓改成柔和邊緣(提示:用 smoothstep 取代 step

總結 🐟

  • Shader 是在 GPU 上執行的小程式,分為 Vertex Shader(處理位置)和 Fragment Shader(處理顏色)
  • GPU 的思維是「每個 pixel 同時獨立計算」,不是 CPU 的「一步一步來」
  • 透過 Uniform 可以從 JS 傳入時間、滑鼠位置等動態資料

推薦學習資源

以上就是從 JS 開發者角度的 Shader 入門,有錯誤還請多多指教。

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

更新於: