URP 渲染实战 / 第 26 章 自定义后处理 / 案例 26.3
案例 26.3Kawase 与 Dual Kawase 模糊进阶
目标:实现移动端常用的两种廉价大半径模糊,并和高斯对比。
Kawase 模糊:每次迭代只在对角线 4 个点各采样一次,偏移随迭代递增(第 i 次为 i + 0.5 纹素)。每次采样落在 4 个纹素中间,双线性过滤让每个采样本身就是一次 2×2 平均,所以 4 次采样覆盖了 16 个纹素;迭代几次之后,结果非常接近高斯。它的半径随迭代次数大约线性增长,每次迭代固定 4 次采样。
Dual Kawase(Marius Bjørge 在 SIGGRAPH 2015 移动图形课程中提出):把迭代放到降采样金字塔上——下行时每级分辨率减半,用 5 次采样的核;上行时每级分辨率加倍,用 8 次采样的核。因为越往下分辨率越低,每级的像素数呈 1/4 递减,总开销几乎与级数无关,而模糊半径随级数指数增长。
Assets/Cookbook/Ch26/KawaseBlur.shader(节选:dual)
// Dual Kawase 下采样:中心权重 4 + 四个对角各 1,共 5 次采样,输出到一半分辨率 half4 FragDown(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; float2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset; half4 c = Tap(uv) * 4.0; c += Tap(uv - h); c += Tap(uv + h); c += Tap(uv + float2(h.x, -h.y)); c += Tap(uv - float2(h.x, -h.y)); return c * (1.0 / 8.0); } // Dual Kawase 上采样:菱形 4 点(权重 1)+ 对角 4 点(权重 2),共 8 次采样,输出到两倍分辨率 half4 FragUp(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; float2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset; half4 c = Tap(uv + float2(-h.x * 2.0, 0.0)); c += Tap(uv + float2( h.x * 2.0, 0.0)); c += Tap(uv + float2(0.0, -h.y * 2.0)); c += Tap(uv + float2(0.0, h.y * 2.0)); c += Tap(uv + float2(-h.x, h.y)) * 2.0; c += Tap(uv + float2( h.x, h.y)) * 2.0; c += Tap(uv + float2( h.x, -h.y)) * 2.0; c += Tap(uv + float2(-h.x, -h.y)) * 2.0; return c * (1.0 / 12.0); }
// Dual Kawase 下采样:中心权重 4 + 四个对角各 1,共 5 次采样,输出到一半分辨率 vec4 FragDown() { vec2 uv = texcoord; vec2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset; vec4 c = Tap(uv) * 4.0; c += Tap(uv - h); c += Tap(uv + h); c += Tap(uv + vec2(h.x, -h.y)); c += Tap(uv - vec2(h.x, -h.y)); return c * (1.0 / 8.0); } // Dual Kawase 上采样:菱形 4 点(权重 1)+ 对角 4 点(权重 2),共 8 次采样,输出到两倍分辨率 vec4 FragUp() { vec2 uv = texcoord; vec2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset; vec4 c = Tap(uv + vec2(-h.x * 2.0, 0.0)); c += Tap(uv + vec2( h.x * 2.0, 0.0)); c += Tap(uv + vec2(0.0, -h.y * 2.0)); c += Tap(uv + vec2(0.0, h.y * 2.0)); c += Tap(uv + vec2(-h.x, h.y)) * 2.0; c += Tap(uv + vec2( h.x, h.y)) * 2.0; c += Tap(uv + vec2( h.x, -h.y)) * 2.0; c += Tap(uv + vec2(-h.x, -h.y)) * 2.0; return c * (1.0 / 12.0); }
Assets/Cookbook/Ch26/KawaseBlur.cs(节选:dual)
void RenderDualKawase(CommandBuffer cmd) { cmd.SetGlobalFloat(s_Offset, m_Offset); // 下行:全分辨率 → 1/2 → 1/4 …,每一步用 5 次采样的 Down 核 RTHandle prev = cameraColor; for (int i = 1; i <= m_Iterations; i++) { Blitter.BlitCameraTexture(cmd, prev, m_Pyramid[i], material, PassDown); prev = m_Pyramid[i]; } // 上行:逐级放大回去,每一步用 8 次采样的 Up 核,最后一步直接写回相机颜色 for (int i = m_Iterations - 1; i >= 0; i--) { RTHandle dst = i == 0 ? cameraColor : m_Pyramid[i]; Blitter.BlitCameraTexture(cmd, m_Pyramid[i + 1], dst, material, PassUp); } }
经典 Kawase 的 C# 循环是“逐次参数必须用 cmd.SetGlobalFloat”的典型例子:每次迭代偏移不同,如果写成 material.SetFloat,5 次迭代全都会用第 5 次的偏移,效果完全不对,而且不会报任何错。
Assets/Cookbook/Ch26/KawaseBlur.cs(节选:kawase)
void RenderKawase(CommandBuffer cmd) { Blitter.BlitCameraTexture(cmd, cameraColor, m_PingA, 0, true); var src = m_PingA; var dst = m_PingB; for (int i = 0; i < m_Iterations; i++) { // 每次迭代偏移不同:用 cmd.SetGlobalFloat 记录进命令缓冲。 // 若用 material.SetFloat,所有迭代最终都会读到最后一次设置的值(见正文) cmd.SetGlobalFloat(s_Offset, (i + 0.5f) * m_Offset); Blitter.BlitCameraTexture(cmd, src, dst, material, PassKawase); (src, dst) = (dst, src); } Blitter.BlitCameraTexture(cmd, src, cameraColor, 0, true); }

| 方法(1080p) | 每像素采样(全分辨率等效) | 半径增长 | 特点 |
|---|---|---|---|
| 可分离高斯,1/2 分辨率,n 次迭代 | 约 2 + 10n × ¼ | ∝ √n | 最“正”,形状可控 |
| Kawase,1/2 分辨率,n 次迭代 | 约 2 + 4n × ¼ | ∝ n | 实现最简单 |
| Dual Kawase,k 级 | 约 8 + 13 × (¼ + 1/16 + …) ≈ 12(最后一级上采样写全分辨率) | ∝ 2^k | 大半径最便宜,移动端首选 |
表中的采样数是按算法结构推算的量级,用来理解趋势;真实耗时取决于带宽、缓存和 GPU 架构,请以目标设备上的 Profiler 为准。