· 9 MIN READ

运动模糊:速度緩衝區與遮罩的取捨

圖形動畫

运动模糊:速度緩衝區與遮罩的取捨 封面插圖
圖 — 运动模糊:速度緩衝區與遮罩的取捨

鏡頭跟拍高速主體時,最廉價的運動模糊(accumulation buffer、AfterimagePass)會把整個畫面糊成一片——因為它不區分「谁在動」。電影感恰恰來自這個區分:背景的運動模糊是資訊,主體的運動模糊是瑕疵。

沒有 MRT 也能算速度#

標準做法是 G-buffer 輸出速度,但 WebGL2 單 attachment 的場合可以用深度重建,兩個 pass 解決。

第一步,本幀深度重建世界座標。 從深度紋理取 d,反投影:

vec4 clip = vec4(uv * 2.0 - 1.0, d * 2.0 - 1.0, 1.0);
vec4 world = invViewProj * clip;
world.xyz /= world.w;

第二步,用上一幀的 viewProjection 重新投影,得到該像素上一幀的螢幕位置:

vec4 prev = prevViewProj * vec4(world.xyz, 1.0);
vec2 prevUV = prev.xy / prev.w * 0.5 + 0.5;
vec2 velocity = (uv - prevUV) * resolution;

這個 velocity 場對靜止幾何自動為零,對鏡頭運動自動全域存在——這是它比物件維度速度更穩的地方:鏡頭甩動時背景本來就該糊。

採樣策略決定條紋與否#

12 個 tap 沿速度方向對稱展開,高斯權重:

vec3 blur(sampler2D tex, vec2 uv, vec2 vel) {
  vec3 sum = texture(tex, uv).rgb;
  float wsum = 1.0;
  float jitter = interleavedGradientNoise(gl_FragCoord.xy);
  for (int i = 1; i <= 6; i++) {
    float t = (float(i) + jitter) / 6.0;
    vec2 off = vel * t * 0.5;          // 半快門
    float w = 1.0 - 0.6 * t * t;
    sum += texture(tex, uv + off).rgb * w;
    sum += texture(tex, uv - off).rgb * w;
    wsum += 2.0 * w;
  }
  return sum / wsum;
}

jitter 那行是關鍵:不抖動 tap 位置,粗速度區會出現一圈圈同心條紋。藍噪聲或 IG noise 把條紋打散成顆粒,顆粒會被後面的膠片顆粒掩蓋,條紋不會。

速度要 clamp(我設 40px),快門角 180° 換算成每幀位移的一半。強度公式 velocity * 0.5 裡這個 0.5 是主體保護係數的另一半含義——

主體遮罩#

渲染主體時寫 stencil(或 depth-only prepass),模糊 pass 讀 mask,把主體像素的速度乘 0.25。效果是:掠過機翼的城市拉成橫向速度線,紙飛機邊緣保持刀銳。人眼讀取這個對比的方式是:「鏡頭在追一個清楚的東西」——这就是追随拍摄(panning)攝影的質感。

不做遮罩,主體邊緣會出現雙影;做了遮罩但係數壓到 0,主體會從速度線裡「剪貼」出來,像貼紙。0.25 是一個保留少量模糊的折衷:邊緣仍有一兩像素的暈,但主體結構完整。

備援與降級#

整合顯卡或手機上,速度重建 + 12 tap 的填充率扛不住。備援是 AfterimagePass(damp 0.72)加輕度徑向模糊:它當然是全域的,但配 0.72 的衰減,主體殘影剛好落在「速度感」而非「壞掉了」的區間。偵測條件用 hardwareConcurrency 和畫素預算就夠了,不需要真的 benchmark。

一句話總結:運動模糊的難點從來不是算法,是谁該糊。