前言

到了系列的最後一篇,我想做一件特別的事:不是教你新的技巧,而是帶你閱讀別人的 shader 程式碼。

Shadertoy 上有成千上萬的作品,從幾行的極簡藝術到幾百行的完整場景。對初學者來說,閱讀這些程式碼往往比自己寫還難——因為作者通常不會寫註釋,變數名稱很簡短,而且各種技巧混在一起。

但是,閱讀別人的 shader 是進步最快的方式之一。今天我們要拆解 2-3 個經典的 Shadertoy 作品,逐行分析它們使用的技巧和數學。看完這篇之後,你再去讀 Shadertoy 上的程式碼,應該會有很不一樣的感覺。


如何閱讀 Shadertoy 程式碼

在開始拆解具體作品之前,先分享一些閱讀策略:

1. 從 mainImage 開始

所有 Shadertoy 程式碼的入口都是 mainImage。從這裡開始,追蹤 fragColor 是怎麼被計算出來的。

2. 識別 UV 正規化方式

幾乎所有 shader 的第一步都是 UV 正規化:

// 最常見的方式:中心為原點,y 範圍 -1 到 1
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;

// 簡化寫法 vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;

// 極簡寫法(你可能在高手的程式碼中看到) vec2 p = (fragCoord / iResolution.y - vec2(.88, .5));

3. 辨認常見函數

  • length()atan()dot() → 距離和角度計算
  • fract()mod() → 重複和空間分割
  • smoothstep()mix() → 平滑過渡和混合
  • sin()cos() → 動畫和波形
  • normalize()reflect() → 光線和法線計算

4. 注意常數

6.2831    // 2 * PI
3.1415    // PI
0.5773    // 1/sqrt(3)
0.57735   // 同上
43758.5453 // 常見的 hash 常數

5. 一個 helper:加入 debug 輸出

不確定某個值是什麼?直接輸出成顏色:

// 把任何值視覺化
fragColor = vec4(vec3(someValue), 1.0); return;

// 把向量視覺化 fragColor = vec4(someVec3 * 0.5 + 0.5, 1.0); return;


作品一:The Universe Within(by BigWIngs)

這是 Shadertoy 上非常受歡迎的一個作品,核心技巧是 fract 空間分割 + cosine palette + 多層疊加

以下是簡化後的程式碼和逐段分析:

vec3 palette(float t) {
    vec3 a = vec3(0.5, 0.5, 0.5);
    vec3 b = vec3(0.5, 0.5, 0.5);
    vec3 c = vec3(1.0, 1.0, 1.0);
    vec3 d = vec3(0.263, 0.416, 0.557);
    return a + b  cos(6.28318  (c * t + d));
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) { // ① UV 正規化 vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y; vec2 uv0 = uv; // 保存原始 UV

vec3 finalColor = vec3(0.0);

// ② 四層疊加 for (float i = 0.0; i < 4.0; i++) { // ③ fract 空間分割 uv = fract(uv * 1.5) - 0.5;

// ④ 距離場 float d = length(uv) * exp(-length(uv0));

// ⑤ 用色盤上色(加入原始 UV 的距離和迭代次數) vec3 col = palette(length(uv0) + i 0.4 + iTime 0.4);

// ⑥ 正弦波形成環狀圖案 d = sin(d * 8.0 + iTime) / 8.0; d = abs(d);

// ⑦ 光暈效果(1/d 形成光暈) d = pow(0.01 / d, 1.2);

// ⑧ 累加 finalColor += col * d; }

fragColor = vec4(finalColor, 1.0); }

逐步拆解

第 ① 步:UV 正規化

標準做法,讓 (0,0) 在畫面中心,y 軸範圍約 -1 到 1。

第 ② 步:四層疊加

整個效果是四層疊加出來的。每一層都用相同的邏輯,但因為 uv 在迴圈中不斷被 fract 分割,所以每一層的尺度不同。

第 ③ 步:fract 空間分割

uv = fract(uv * 1.5) - 0.5;

這是最關鍵的一行。fract(uv * 1.5) 把空間分割成重複的格子,- 0.5 讓每個格子的中心在 (0, 0)。每次迴圈都在更細的尺度上做分割,產生碎形般的效果。

第 ④ 步:距離場

float d = length(uv) * exp(-length(uv0));

length(uv) 是到當前格子中心的距離。exp(-length(uv0)) 用原始 UV 的距離做衰減——離畫面中心越遠,距離值越小,效果越暗淡。

第 ⑤ 步:色盤

vec3 col = palette(length(uv0) + i  0.4 + iTime  0.4);

用原始 UV 的距離 + 迭代層數 + 時間 來決定顏色。這讓每一層有不同的顏色,而且整體色彩會隨時間流動。

第 ⑥ 步:正弦波

d = sin(d * 8.0 + iTime) / 8.0;
d = abs(d);

把距離值通過 sin 函數,產生同心環狀的圖案。abs 讓正負值都變成正的,形成更密集的環。

第 ⑦ 步:光暈

d = pow(0.01 / d, 1.2);

0.01 / dd 接近零(也就是環的位置)時產生很亮的值,形成光暈效果。pow(..., 1.2) 稍微壓縮亮部。

第 ⑧ 步:累加

四層的結果加在一起。因為 HDR 值可能超過 1.0,但顯示器會自動 clamp,所以亮的地方會過曝,產生類似 bloom 的效果。

學到的技巧

  • fract 空間分割創造碎形
  • exp(-length) 做全域衰減
  • sin(d) / d 形式產生環狀圖案
  • 1/d 產生光暈
  • 多層疊加增加複雜度
  • cosine palette 統一色彩風格

作品二:Seascape(by TDM)

Seascape 是 Shadertoy 歷史上最經典的海景 shader 之一。它用 ray marching 渲染了一片逼真的海洋。我們來看它的核心部分。

海浪函數

float sea_octave(vec2 uv, float choppy) {
    uv += noise(uv);
    vec2 wv = 1.0 - abs(sin(uv));
    vec2 swv = abs(cos(uv));
    wv = mix(wv, swv, wv);
    return pow(1.0 - pow(wv.x * wv.y, 0.65), choppy);
}

拆解:

  1. uv += noise(uv):加上 noise 讓波浪不規則
  2. 1.0 - abs(sin(uv)):產生尖銳的波峰(abs(sin) 是三角波,1.0 - 反轉成波峰朝上)
  3. abs(cos(uv)):另一組波
  4. mix(wv, swv, wv):用 wv 自身做插值權重,這會讓波峰更尖銳
  5. pow(..., choppy)choppy 控制波浪的陡峭程度

多層海浪(類似 fBm)

float map(vec3 p) {
    float freq = 0.16;
    float amp = 0.6;
    float choppy = 4.0;
    vec2 uv = p.xz;

float d, h = 0.0; for (int i = 0; i < 5; i++) { d = sea_octave((uv + iTime) * freq, choppy); d += sea_octave((uv - iTime) * freq, choppy); h += d * amp; uv *= mat2(1.6, 1.2, -1.2, 1.6); // 旋轉 + 縮放 freq *= 1.9; amp *= 0.22; choppy = mix(choppy, 1.0, 0.2); } return p.y - h; }

這就是一個特製的 fBm:

  • 每一層(octave)使用 sea_octave 而非普通 noise
  • 兩次呼叫(+iTime-iTime)讓波往兩個方向移動
  • uv *= mat2(...) 是旋轉 + 縮放,等同於 lacunarity
  • amp *= 0.22 是 gain
  • choppy 逐層遞減,讓大波陡峭、小波平滑

光照

vec3 getColor(vec3 p, vec3 n, vec3 l, vec3 eye, vec3 dist) {
    // 漫射光
    float dif = clamp(dot(n, l), 0.0, 1.0);

// Fresnel(掠角更亮) float fres = pow(clamp(1.0 - dot(n, -eye), 0.0, 1.0), 3.0);

// 反射 vec3 reflected = skyColor(reflect(eye, n));

// 海水顏色 vec3 waterColor = vec3(0.0, 0.04, 0.08);

// 散射(光線穿過波峰的效果) float scatter = max(dot(n, l) * 0.5 + 0.5, 0.0); vec3 scatterColor = vec3(0.0, 0.3, 0.4) * scatter;

// 混合 vec3 col = waterColor + dif vec3(0.8) 0.12; col += scatterColor; col = mix(col, reflected, fres);

// 高光 float spec = pow(max(dot(reflect(eye, n), l), 0.0), 100.0); col += vec3(spec);

return col; }

關鍵技巧:

  • Fresnel 效果讓邊緣(掠角)反射更強
  • 散射(scatter)模擬光穿過薄薄的浪峰時的半透明感
  • reflect 計算反射方向,讀取天空顏色

學到的技巧

  • 特製的 noise 函數可以模擬特定的自然現象
  • 1.0 - abs(sin(x)) 產生尖銳波峰
  • fBm 的變體:每一層可以有不同的行為
  • Fresnel + scatter + specular 的組合渲染水面

作品三:Happy Jumping(by iq)

iquilezles 的 “Happy Jumping” 是一個完整的 3D 角色動畫場景,全部在一個 fragment shader 裡完成。這裡我們只看它的幾個核心技巧。

SDF 組合角色

float sdGuy(vec3 p) {
    float t = iTime;

// 彈跳動畫 float y = 4.0 abs(fract(t 0.5) - 0.5); y = 1.0 - y; y = 1.0 - y * y;

// 身體(壓扁的球) vec3 bodyPos = vec3(0.0, y * 0.5, 0.0); float squash = 1.0 + 0.3 * (1.0 - y); // 落地時壓扁 vec3 bp = p - bodyPos; bp.y /= squash; float body = sdSphere(bp, 0.25) * squash;

// 頭(球) vec3 headPos = bodyPos + vec3(0.0, 0.28 * squash, 0.0); float head = sdSphere(p - headPos, 0.2);

// smooth union 身體和頭 float d = smin(body, head, 0.1);

return d; }

技巧:

  • 彈跳動畫:abs(fract(t) - 0.5) 產生三角波,再用 1.0 - y*y 做緩入緩出
  • Squash & Stretch:落地時 y 方向壓縮(bp.y /= squash),然後用 * squash 補償 SDF 的正確性
  • smooth union 讓身體和頭自然連接

地面的陰影模式

iq 在地面上用了一個漂亮的假陰影技巧:

// 角色的「投影」——簡化為一個橢圓
float shadow = sdCircle(p.xz - characterPos.xz, 0.3);
shadow = smoothstep(0.3, 0.0, shadow); // 柔和邊緣
shadow *= 0.5; // 半透明陰影

這比真正的陰影計算便宜得多,但視覺上非常有效。

臉部表情

即使是在 SDF 中,你也可以做出表情:

// 眼睛(兩個小球)
float eyeL = sdSphere(p - headPos - vec3(-0.08, 0.05, -0.15), 0.04);
float eyeR = sdSphere(p - headPos - vec3( 0.08, 0.05, -0.15), 0.04);

// 嘴巴(用 subtraction 挖出一個微笑) float mouth = sdBox(p - headPos - vec3(0.0, -0.05, -0.15), vec3(0.06, 0.01, 0.01));

學到的技巧

  • 用數學函數(fract、二次曲線)做骨骼動畫
  • Squash & Stretch 用 SDF 空間變形實現
  • 假陰影:投影到地面的簡化 SDF
  • SDF 可以做出有表情的角色

常見 Shadertoy 技巧速查

整理一下在各種作品中反覆出現的技巧:

常見的 hash/noise

// 一行 hash
float hash(float n) { return fract(sin(n) * 43758.5453); }
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }

常見的 UV 技巧

// 正規化(中心為原點)
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;

// Tile(重複) vec2 id = floor(uv); // 格子 ID uv = fract(uv) - 0.5; // 格子內的座標

常見的光照

// 漫射
float dif = clamp(dot(n, l), 0.0, 1.0);

// 半蘭伯特(wrap lighting) float dif = dot(n, l) * 0.5 + 0.5;

// 鏡面反射 float spec = pow(max(dot(reflect(-l, n), -rd), 0.0), 32.0);

// 邊緣光(rim light) float rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0);

常見的色彩後處理

// gamma 校正
col = pow(col, vec3(1.0/2.2));

// 對比度 col = smoothstep(0.0, 1.0, col);

// 飽和度 float grey = dot(col, vec3(0.299, 0.587, 0.114)); col = mix(vec3(grey), col, 1.2); // > 1 增加飽和度

// 暈影 col = 1.0 - 0.5 dot(uv, uv);

常見的動畫

// 三角波(彈跳)
float bounce = abs(fract(t) - 0.5) * 2.0;

// 緩入緩出 float ease = t t (3.0 - 2.0 * t); // = smoothstep(0, 1, t)

// 脈動 float pulse = sin(t 6.28) 0.5 + 0.5;


我的閱讀流程

最後分享一下我個人閱讀 Shadertoy 作品的流程:

  1. 先看效果:跑一遍 shader,觀察有哪些視覺元素
  2. 看 mainImage:找到入口,理解整體架構(有幾個 pass、主要的計算流程)
  3. 辨認技巧:UV 正規化方式、是否有 ray marching、用了什麼 SDF
  4. 逐函數分析:從最底層的工具函數開始,往上追蹤
  5. 修改參數:改數字看效果變化,這是理解程式碼最快的方式
  6. 加 debug 輸出:不確定的中間值直接輸出成顏色
  7. 記筆記:把學到的技巧記下來,加到自己的工具箱

小結

閱讀別人的 shader 是一種需要練習的技能。一開始可能很挫折,但隨著你的技巧庫越來越豐富,你會越來越快地辨認出各種模式:

  1. 從 mainImage 開始,追蹤顏色的計算流程
  2. 辨認常見模式:UV 正規化、noise/hash、SDF、光照模型
  3. The Universe Within:fract 碎形 + cosine palette + 多層疊加
  4. Seascape:特製 noise 做海浪 + fBm 變體 + Fresnel 水面
  5. Happy Jumping:SDF 角色 + 數學動畫 + squash & stretch
  6. 修改是最好的學習:改參數、改顏色、改形狀,看看會發生什麼

這一系列文章到這裡就結束了。從基礎的 noise、SDF,到 ray marching、fBm、domain warping、極座標、色盤、互動控制,最後到閱讀經典作品——希望這些內容能幫助你在 shader 程式設計的道路上走得更遠。

Shader 是程式設計和數學和藝術的交叉點。每寫一個新的 shader,你都在用數學創造一個小宇宙。享受這個過程吧。

延伸閱讀