前言

當你辛辛苦苦在 shader 裡用 ray marching 渲染出一個漂亮的 3D 場景之後,有沒有覺得畫面太「乾淨」了?真實的影像——無論是電影、攝影、還是遊戲——幾乎都會加上後處理(post-processing)效果來增添風味。

後處理是在主要渲染完成之後,對整張畫面進行的影像處理。在 Shadertoy 裡,我們可以用 Buffer 來實現多 pass 的流程。在其他框架(如 p5.js + shader、Three.js),則通常透過 framebuffer 或 render target 來完成。

今天我們來實作四個經典的後處理特效:高斯模糊色差暈影故障藝術


多 Pass 處理的概念

後處理的核心概念是「多 pass」:

  1. Pass 1(Buffer A):渲染你的主場景,輸出到一張紋理(texture)
  2. 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); }


組合所有後處理效果

實務上,你通常會把多個後處理效果串在一起。順序很重要——一般來說:

  1. 先做 Bloom(需要模糊)
  2. 色差
  3. 色調映射(tone mapping)
  4. 暈影
  5. Glitch(如果需要)
  6. 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」升級為「有氛圍的作品」的關鍵步驟:

  1. 高斯模糊:可分離的兩 pass 模糊,也是 Bloom 效果的基礎
  2. 色差:模擬鏡頭色散,邊緣處 RGB 分離
  3. 暈影:引導視覺焦點到中心
  4. 故障藝術:數位錯誤美學,掃描線偏移 + 色彩分離 + 雜訊

這些效果單獨看都很簡單,但組合起來威力驚人。很多 Shadertoy 作品之所以好看,有一半功勞在後處理上。

延伸閱讀

下一篇我們來深入探討 fBm(Fractional Brownian Motion),看看如何用多層 noise 疊加出雲霧、山脈和火焰的效果。