前言
到了系列的最後一篇,我想做一件特別的事:不是教你新的技巧,而是帶你閱讀別人的 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 / d 在 d 接近零(也就是環的位置)時產生很亮的值,形成光暈效果。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);
}
拆解:
uv += noise(uv):加上 noise 讓波浪不規則1.0 - abs(sin(uv)):產生尖銳的波峰(abs(sin)是三角波,1.0 -反轉成波峰朝上)abs(cos(uv)):另一組波mix(wv, swv, wv):用 wv 自身做插值權重,這會讓波峰更尖銳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(...)是旋轉 + 縮放,等同於 lacunarityamp *= 0.22是 gainchoppy逐層遞減,讓大波陡峭、小波平滑
光照
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 作品的流程:
- 先看效果:跑一遍 shader,觀察有哪些視覺元素
- 看 mainImage:找到入口,理解整體架構(有幾個 pass、主要的計算流程)
- 辨認技巧:UV 正規化方式、是否有 ray marching、用了什麼 SDF
- 逐函數分析:從最底層的工具函數開始,往上追蹤
- 修改參數:改數字看效果變化,這是理解程式碼最快的方式
- 加 debug 輸出:不確定的中間值直接輸出成顏色
- 記筆記:把學到的技巧記下來,加到自己的工具箱
小結
閱讀別人的 shader 是一種需要練習的技能。一開始可能很挫折,但隨著你的技巧庫越來越豐富,你會越來越快地辨認出各種模式:
- 從 mainImage 開始,追蹤顏色的計算流程
- 辨認常見模式:UV 正規化、noise/hash、SDF、光照模型
- The Universe Within:fract 碎形 + cosine palette + 多層疊加
- Seascape:特製 noise 做海浪 + fBm 變體 + Fresnel 水面
- Happy Jumping:SDF 角色 + 數學動畫 + squash & stretch
- 修改是最好的學習:改參數、改顏色、改形狀,看看會發生什麼
這一系列文章到這裡就結束了。從基礎的 noise、SDF,到 ray marching、fBm、domain warping、極座標、色盤、互動控制,最後到閱讀經典作品——希望這些內容能幫助你在 shader 程式設計的道路上走得更遠。
Shader 是程式設計和數學和藝術的交叉點。每寫一個新的 shader,你都在用數學創造一個小宇宙。享受這個過程吧。
延伸閱讀
- Shadertoy:最大的 shader 社群和平台
- iquilezles.org:所有 shader 技巧的聖經
- The Book of Shaders:最好的入門教科書
- Art of Code YouTube:BigWIngs 的 shader 教學影片
- Inigo Quilez YouTube:iq 本人的創作和教學影片