前言
當你辛辛苦苦在 shader 裡用 ray marching 渲染出一個漂亮的 3D 場景之後,有沒有覺得畫面太「乾淨」了?真實的影像——無論是電影、攝影、還是遊戲——幾乎都會加上後處理(post-processing)效果來增添風味。
後處理是在主要渲染完成之後,對整張畫面進行的影像處理。在 Shadertoy 裡,我們可以用 Buffer 來實現多 pass 的流程。在其他框架(如 p5.js + shader、Three.js),則通常透過 framebuffer 或 render target 來完成。
今天我們來實作四個經典的後處理特效:高斯模糊、色差、暈影和故障藝術。
多 Pass 處理的概念
後處理的核心概念是「多 pass」:
- Pass 1(Buffer A):渲染你的主場景,輸出到一張紋理(texture)
- Pass 2(Image):讀取 Buffer A 的紋理,對每個像素做後處理運算
在 Shadertoy 中,Buffer A 的輸出可以透過 iChannel0 在 Image pass 中讀取。
// Buffer A: 渲染主場景
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// ... 你的 ray marching 場景 ...
fragColor = vec4(col, 1.0);
}
// Image: 後處理
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec3 col = texture(iChannel0, uv).rgb; // 讀取 Buffer A
// ... 後處理效果 ...
fragColor = vec4(col, 1.0);
}
高斯模糊(Gaussian Blur)
高斯模糊是最常見的模糊方式。原理是對每個像素,用高斯權重取周圍像素的加權平均。
簡單的 box blur
先看最簡單的均值模糊:
vec3 boxBlur(sampler2D tex, vec2 uv, vec2 texelSize, float radius) {
vec3 col = vec3(0.0);
float count = 0.0;
for (float x = -radius; x <= radius; x += 1.0) {
for (float y = -radius; y <= radius; y += 1.0) {
col += texture(tex, uv + vec2(x, y) * texelSize).rgb;
count += 1.0;
}
}
return col / count;
}
高斯模糊(可分離版本)
真正的高斯模糊可以分成水平和垂直兩個 pass,大幅降低取樣次數:
// 高斯權重(9-tap)
const float weights[5] = float[](
0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216
);
// 水平 pass
vec3 gaussianBlurH(sampler2D tex, vec2 uv, vec2 texelSize) {
vec3 result = texture(tex, uv).rgb * weights[0];
for (int i = 1; i < 5; i++) {
result += texture(tex, uv + vec2(texelSize.x float(i), 0.0)).rgb weights[i];
result += texture(tex, uv - vec2(texelSize.x float(i), 0.0)).rgb weights[i];
}
return result;
}
// 垂直 pass
vec3 gaussianBlurV(sampler2D tex, vec2 uv, vec2 texelSize) {
vec3 result = texture(tex, uv).rgb * weights[0];
for (int i = 1; i < 5; i++) {
result += texture(tex, uv + vec2(0.0, texelSize.y float(i))).rgb weights[i];
result += texture(tex, uv - vec2(0.0, texelSize.y float(i))).rgb weights[i];
}
return result;
}
在 Shadertoy 中,你需要兩個 buffer 來分別做水平和垂直的 pass。
用模糊做光暈(Bloom)
Bloom 效果是先提取畫面中的亮部(thresholding),然後模糊亮部,最後疊回原圖:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec2 texel = 1.0 / iResolution.xy;
vec3 original = texture(iChannel0, uv).rgb;
// 模糊版本(假設 iChannel1 是模糊後的亮部)
vec3 bloom = texture(iChannel1, uv).rgb;
// 疊加
vec3 col = original + bloom * 0.5;
// tone mapping
col = col / (1.0 + col);
fragColor = vec4(col, 1.0);
}
色差(Chromatic Aberration)
色差是光學鏡頭的一種瑕疵——不同波長的光折射角度不同,導致 RGB 三個顏色通道在影像邊緣會稍微錯開。在遊戲和影片中,這被當作一種風格化效果來使用。
基礎實作
最簡單的做法是分別對 R、G、B 三個通道用略微不同的 UV 座標來取樣:
vec3 chromaticAberration(sampler2D tex, vec2 uv, float amount) {
vec2 offset = (uv - 0.5) * amount; // 從中心往外的偏移
float r = texture(tex, uv + offset).r;
float g = texture(tex, uv).g;
float b = texture(tex, uv - offset).b;
return vec3(r, g, b);
}
這個偏移量 (uv - 0.5) * amount 會讓越靠近邊緣的地方色差越大,中心幾乎沒有——這符合真實鏡頭的特性。
進階:沿徑向方向的色差
更精細的版本會沿著徑向方向做多次取樣來模擬色散:
vec3 chromaticAberrationPro(sampler2D tex, vec2 uv, float maxOffset) {
vec2 dir = uv - 0.5;
float dist = length(dir);
vec2 offset = dir * maxOffset;
const int SAMPLES = 8;
vec3 col = vec3(0.0);
for (int i = 0; i < SAMPLES; i++) {
float t = float(i) / float(SAMPLES - 1); // 0 到 1
vec2 sampleUV = uv + offset (t - 0.5) 2.0;
vec3 sampleCol = texture(tex, sampleUV).rgb;
// 用不同的 t 值來加權 RGB
// t=0 偏紅,t=0.5 偏綠,t=1 偏藍
vec3 weight = vec3(
1.0 - t,
1.0 - abs(t - 0.5) * 2.0,
t
);
col += sampleCol * weight;
}
return col / float(SAMPLES) * 2.0;
}
暈影(Vignette)
暈影是畫面邊緣變暗的效果,非常常見也非常簡單。它可以幫助引導觀眾的視線到畫面中心。
基礎版本
float vignette(vec2 uv, float intensity, float extent) {
uv = uv * 2.0 - 1.0; // 轉換到 -1 ~ 1
float vig = 1.0 - dot(uv intensity, uv intensity);
return clamp(pow(vig, extent), 0.0, 1.0);
}
// 使用方式
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec3 col = texture(iChannel0, uv).rgb;
col *= vignette(uv, 0.6, 1.5);
fragColor = vec4(col, 1.0);
}
有色暈影
不一定要黑色暈影,你也可以用其他顏色:
vec3 colorVignette(vec3 col, vec2 uv, vec3 vigColor, float strength) {
uv = uv * 2.0 - 1.0;
float d = length(uv);
float vig = smoothstep(0.4, 1.4, d);
return mix(col, vigColor, vig * strength);
}
// 暖色調暈影
col = colorVignette(col, uv, vec3(0.1, 0.05, 0.0), 0.8);
故障藝術(Glitch Effect)
故障藝術(Glitch Art)模擬數位訊號錯誤的視覺效果——掃描線錯位、色彩偏移、隨機雜訊。這是近年來非常受歡迎的風格。
隨機函數
首先需要一個偽隨機函數:
float random(float x) {
return fract(sin(x 12.9898) 43758.5453);
}
float random2(vec2 st) {
return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453);
}
水平掃描線偏移
vec3 glitchOffset(sampler2D tex, vec2 uv, float time) {
// 每隔一段時間觸發一次 glitch
float glitchTime = floor(time * 4.0); // 控制 glitch 頻率
float trigger = step(0.8, random(glitchTime)); // 80% 時間正常
// 隨機選擇要偏移的行
float lineJitter = 0.0;
if (trigger > 0.5) {
float blockY = floor(uv.y * 20.0); // 把畫面分成 20 個水平區塊
float shouldShift = step(0.7, random(blockY + glitchTime));
lineJitter = shouldShift (random(blockY glitchTime) - 0.5) * 0.1;
}
return texture(tex, vec2(uv.x + lineJitter, uv.y)).rgb;
}
RGB 通道分離 + 掃描線
vec3 glitchEffect(sampler2D tex, vec2 uv, float time) {
float glitchTime = floor(time * 3.0);
float glitchStrength = step(0.85, random(glitchTime)) * 0.03;
// RGB 通道分離
float r = texture(tex, uv + vec2(glitchStrength, 0.0)).r;
float g = texture(tex, uv).g;
float b = texture(tex, uv - vec2(glitchStrength, 0.0)).b;
vec3 col = vec3(r, g, b);
// 掃描線
float scanline = sin(uv.y 800.0) 0.04;
col -= scanline;
// 隨機雜訊
float noise = random2(uv + time) * 0.05;
col += noise;
return col;
}
完整的故障效果組合
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
float time = iTime;
// 基礎影像
vec3 col = texture(iChannel0, uv).rgb;
// 決定 glitch 強度(間歇性觸發)
float glitchPhase = floor(time * 2.0);
float glitchActive = step(0.7, random(glitchPhase));
float intensity = glitchActive (0.5 + 0.5 random(glitchPhase + 1.0));
if (intensity > 0.1) {
// 水平區塊偏移
float blockSize = floor(uv.y * 30.0);
float blockRand = random(blockSize + glitchPhase);
float shift = (blockRand - 0.5) 0.15 intensity;
if (blockRand > 0.6) {
uv.x += shift;
}
// 色差分離
float chromaShift = intensity * 0.02;
col.r = texture(iChannel0, uv + vec2(chromaShift, 0.0)).r;
col.g = texture(iChannel0, uv).g;
col.b = texture(iChannel0, uv - vec2(chromaShift, 0.0)).b;
// 隨機亮度跳變
col = 1.0 + (random(time) - 0.5) intensity * 0.5;
}
// 持續性的掃描線(subtle)
col = 0.95 + 0.05 sin(uv.y 400.0 + time 10.0);
// CRT 曲面模擬(可選)
vec2 crtUV = uv * 2.0 - 1.0;
float crtDist = dot(crtUV, crtUV);
col = 1.0 - crtDist 0.1;
fragColor = vec4(col, 1.0);
}
組合所有後處理效果
實務上,你通常會把多個後處理效果串在一起。順序很重要——一般來說:
- 先做 Bloom(需要模糊)
- 色差
- 色調映射(tone mapping)
- 暈影
- Glitch(如果需要)
- Gamma 校正
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
// 讀取渲染結果
vec3 col = texture(iChannel0, uv).rgb;
// 1. 色差
col = chromaticAberration(iChannel0, uv, 0.003);
// 2. 暈影
col *= vignette(uv, 0.5, 1.2);
// 3. Glitch(用 iTime 控制)
// col = glitchEffect(iChannel0, uv, iTime);
// 4. 色調調整
col = pow(col, vec3(0.95)); // 稍微提亮
col = mix(col, vec3(dot(col, vec3(0.299, 0.587, 0.114))), -0.1); // 微增飽和度
// 5. Gamma 校正
col = pow(col, vec3(1.0 / 2.2));
fragColor = vec4(col, 1.0);
}
小結
後處理是讓畫面從「技術 demo」升級為「有氛圍的作品」的關鍵步驟:
- 高斯模糊:可分離的兩 pass 模糊,也是 Bloom 效果的基礎
- 色差:模擬鏡頭色散,邊緣處 RGB 分離
- 暈影:引導視覺焦點到中心
- 故障藝術:數位錯誤美學,掃描線偏移 + 色彩分離 + 雜訊
這些效果單獨看都很簡單,但組合起來威力驚人。很多 Shadertoy 作品之所以好看,有一半功勞在後處理上。
延伸閱讀
- Shadertoy — Post Processing Effects:各種後處理效果的實作
- GPU Gems — Real-Time Glow:經典的 Bloom 實作參考
- Glitch Art in GLSL:Shadertoy 上的各種 glitch 效果
下一篇我們來深入探討 fBm(Fractional Brownian Motion),看看如何用多層 noise 疊加出雲霧、山脈和火焰的效果。