---
url: /column-shader-notes/01-shader-intro.md
description: 從 JavaScript 開發者的角度出發，一起來看看 Shader 的核心概念吧！(｢･ω･)｢✧
---

![shader-notes](/shader-notes.webp){.cover}

# 鱈魚的 Shader 筆記 - EP01：甚麼是 Shader

大家好，我是鱈魚。%(｡･∀･)ﾉﾞ%

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

曾經我也很熱衷使用[純 CSS 實現各種視覺效果](https://codepen.io/collection/GoqZzQ)，所以為甚麼還要學 Shader 呢？

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

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

剛好最近很常做[基於 Shader 的效果](https://chillcomponent.codlin.me/components/bg-starry-sea/)，想說還是來寫個筆記，以免自己金魚腦，常常忘記。

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

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

路人：「JS 不熟，只會框架怎麼辦？%(◜௰◝)%」

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

::: tip 關於 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 是「一口氣處理所有像素」。

鱈魚：「和鱈魚和油魚一樣，差一個字差很多 %(・∀・)９%」

路人：「隨便拉，看起來都很肥 %(。-\`ω´-)%」

鱈魚：「我說那個尊重呢？%(╥ω╥\`)%」

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

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。

```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。

簡單說，z 控制深度（誰擋住誰），w 和透視投影有關（近大遠小）。2D 用不到這些，所以 z 固定 `0.0`、w 固定 `1.0` 就好。詳細原理會在 [EP02](./260407.02-vertex-shader) 說明。

準備好頂點資料後，JavaScript 會呼叫 `gl.drawArrays(mode, first, count)` 告訴 GPU「開始畫」。

第一個參數 `mode` 決定 GPU 怎麼連接這些頂點。

常見的繪圖模式有：

**點**

`gl.POINTS`：每個頂點畫一個點，大小由 `gl_PointSize` 決定。

**線**

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

**三角形**

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

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

有了這個全螢幕四邊形，Fragment Shader 就能在畫面上的每個像素執行。

現在看不太懂沒關係，[EP02](./260407.02-vertex-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 / 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 需要好幾行的事。

```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 * 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：你的第一個像素函式

理論夠多了，讓我們來動手看看吧！%(・∀・)９%

Fragment Shader 核心概念相當簡單：

每個 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 就偏了）
* 很多數學函式（`sin`、`smoothstep`）在 0~1 的範圍內最好用
* 別人的 Shader 範例幾乎都用正規化座標，不轉換就看不懂

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

```glsl
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 正規化

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

解法很簡單，把 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`，能用純數學運算就用數學運算。%(⌐■\_■)✧%

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

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

### 挑戰時間

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

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

## 總結 🐟

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

### 推薦學習資源

* [The Book of Shaders](https://thebookofshaders.com/)：最經典的 Shader 互動教學
* [Shadertoy](https://www.shadertoy.com/)：Shader 界的 CodePen，大量範例可以拆來學

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

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