ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity Shader屏幕后处理实战:边缘检测、高斯模糊、Bloom与运动模糊

Unity Shader屏幕后处理实战:边缘检测、高斯模糊、Bloom与运动模糊 做技术美术这行Shader是绕不开的坎而屏幕后处理Screen Effect / Image Effect又是Shader里最实用、最能“出片”的技能之一。我在自学《Unity Shader 入门精要》第12章时前后花了两周时间才把整章代码吃透。如果你也在啃这本书或者是刚入行想做TA、想做渲染相关工作的朋友这一章值得反复研究。这章的核心是“后处理框架”怎么在Unity里写一个全屏特效脚本怎么用C#往Shader里传参怎么用Shader处理整张屏幕图像。边缘检测、高斯模糊、Bloom泛光、运动模糊这四个“老朋友”在这一章里全齐了。学完之后你会发现之前很多游戏里“看起来高级”的画面效果其实就是在这套框架上改参数——这才是这一章真正的含金量。1. 先搞清楚后处理到底在做什么后处理的全称是“屏幕空间后期处理”它的工作方式和我们平时玩游戏时手机上的滤镜软件非常像先把拍好的照片放进Photoshop然后对整个画面统一加滤镜、调亮度、做模糊而不是对照片里的某一个物体单独处理。Unity里的后处理也走这个逻辑相机先把整个场景渲染成一张图像然后我们用另一套Shader在这张图像上“二次加工”最后把加工后的结果输出到屏幕上。这个“先渲染、再加工”的过程用到的关键接口是OnRenderImage。它是MonoBehaviour里的一个回调函数在相机完成所有渲染之后、还没把图像送到屏幕之前触发。我们在脚本里用Graphics.Blit把源纹理传给Shader处理再输出到目标纹理上。整个过程听起来不高深但它是所有屏幕特效的地基。第12章的实战代码给了我们一个非常标准的结构C#脚本里先绑定材质、检查Shader是否可用然后把参数值传给ShaderShader里定义Properties块接收参数在Fragment阶段对整张屏幕纹理做像素级操作。这个结构太常用了以至于我现在写任何后处理效果第一件事就是先把这个框架搭出来再往里面填逻辑。有一个细节容易被忽略Graphics.Blit有三个常用重载Blit(src, dst)是把src直接拷贝到dstBlit(src, dst, mat)是用mat的默认Pass处理Blit(src, dst, mat, pass)是用mat指定的某一个Pass处理。第12章里常用的是第二个和第三个。做多Pass效果时比如高斯模糊先水平再垂直就要靠指定Pass序号来区分。还有一个我在自学的过程中反复踩坑的地方OnRenderImage只能在内置渲染管线的标准相机上直接使用。如果你打开项目后用的是URP通用渲染管线需要先确认是不是内置管线否则脚本会直接报错或根本不执行。我最初用的Unity版本默认渲染管线是内置所以能直接跑后来换成URP项目就彻底失效了。如果你也用URP可以考虑用ScriptableRendererFeature或者CommandBuffer来做类似的屏幕特效但这已经超出第12章的内容了。初学阶段建项目时直接选内置渲染管线最省事。2. 边缘检测卷积算子和“看邻居脸色”的像素2.1 像素计算为什么要看邻域边缘检测在图像处理里是个老话题它的直觉非常朴素一个像素如果是“边缘”那它和旁边的像素在颜色、亮度上的差异一定很大。如果一个人站在白色墙前面他的衣服颜色和墙面颜色差异巨大计算机就能靠着这种差异把人的轮廓抠出来。要实现这个效果我们需要有一个“窗口”它能同时看到某个像素以及它周围的像素。这个窗口在图形学里叫卷积核Kernel或算子。第12章里用的Sobel算子就是一个3x3的窗口它定义了每个邻居对中心像素的影响权重。比如水平方向检测就特别关注左邻右舍的差值垂直方向检测就特别关注上下的差值。代码里的实现方式就是在当前像素的UV坐标上分别偏移_MainTex_TexelSize.xy的整数倍采样周围的9个点再分别乘以Sobel核对应的权重累加得到梯度值。最后用梯度和阈值比较小于阈值就认为是边缘返回黑色否则返回原色。_MainTex_TexelSize是Unity自动提供给Shader的一个内置变量表示主纹理每个纹素的大小。简单理解就是1/纹理宽度和1/纹理高度。比如一张1024x1024的贴图它的_MainTex_TexelSize就是(1/1024, 1/1024, 1024, 1024)。有了它我们才能从当前像素跳到它的邻居像素去采样。2.2 Sobel算子的具体计算过程在Shader里边缘检测的Fragment函数通常会做这几件事先定义3x3的邻域坐标用一个half2 uv[9]数组存下来每个元素是i.uv 偏移 * _MainTex_TexelSize.xy。对每个坐标采样颜色并用Luminance函数转换成亮度值灰度值。用Sobel核的两个方向矩阵分别求水平梯度和垂直梯度。计算梯度大小sqrt(Gx^2 Gy^2)和_EdgeThreshold比较输出边缘色或原色。这里我贴一段我实际改过的核心代码方便你对照理解half Sobel(v2f i) { const half Gx[9] {-1, -2, -1, 0, 0, 0, 1, 2, 1}; const half Gy[9] {-1, 0, 1, -2, 0, 2, -1, 0, 1}; half texColor; half edgeX 0; half edgeY 0; for (int it 0; it 9; it) { texColor Luminance(tex2D(_MainTex, i.uv[it]).rgb); edgeX texColor * Gx[it]; edgeY texColor * Gy[it]; } return 1 - abs(edgeX) - abs(edgeY); }上面返回的值越接近1说明这块区域越“平滑”不是边缘越接近0说明越可能是边缘。这个结果可以进一步用_EdgeColor和_BackgroundColor做插值。2.3 实操中的两个经验先说阈值。_EdgeThreshold这个参数如果设置得太小很多颜色差异不明显的边缘会被漏掉比如深灰色衣服和黑色裤子交界处设置得太大整张画面会充满黑边像老漫画一样。我个人的经验是0.1到0.2之间是一个比较稳妥的区间。但这完全取决于画面内容最好在运行时用一个Slider实时调节。第二个经验是关于“法线边缘检测”。用颜色亮度做边缘检测遇到颜色相近的物体交界处会失效比如白色衣服和白色墙壁。更好的方案是用相机的深度纹理和法线纹理来做边缘检测对应书中后续章节的部分。深度法线模式的核心思路是只要两个物体在深度上不连续或者法线方向差异过大就是边缘。这种方式对颜色不敏感效果稳定得多。第12章从颜色检测讲起是为了让学习曲线更平滑先把卷积思路搞明白后面换算子就是顺手的事。3. 高斯模糊与Bloom让光“溢”出来3.1 高斯模糊是怎么做到“自然”的模糊看起来简单但直接对所有像素取平均值会造成很生硬的“块状感”。高斯模糊的思想是离中心像素越近的邻居对结果的影响越大越远影响越小。这个权重分布符合高斯函数也就是正态分布的曲线所以叫高斯模糊。如果直接按标准的5x5高斯核采样需要25次纹理采样在移动设备上开销偏大。第12章里给了一个优化策略把二维高斯核拆成两个一维核——先水平模糊再垂直模糊。这样总采样次数从25次降到10次。这个思路叫“可分离卷积”是图像处理里的经典优化套路。更进一步的优化是把两次相邻的采样合并利用纹理过滤硬件自动插值的特性把高斯权重预先叠加到采样偏移上。我用下来最直观的体验是原本5x5的核用3次采样就能近似完成效率高一截。书里给的实现就做了这种合并我在自己项目里也复刻了一份性能确实稳。3.2 Bloom的实现流程Bloom效果也叫泛光、辉光是“高亮区域向周围扩散”的光晕效果。它的实现思路比较固定分三步第一步从原图中提取亮部也就是把高于某个亮度阈值的像素保留下来其余变黑。第二步把提取出来的亮部图做高斯模糊让这些高亮区域颜色“晕开”。第三步把模糊后的亮部图和原图叠加得到最终画面。我自己修改过的提取亮部Shader核心逻辑很简单fixed4 frag(v2f i) : SV_Target { fixed4 c tex2D(_MainTex, i.uv); fixed luminance Luminance(c.rgb); return luminance _BloomThreshold ? c * _BloomIntensity : fixed4(0, 0, 0, 1); }亮度阈值由C#脚本里的_BloomThreshold控制。_BloomIntensity控制亮部增强的力度数值越大泛光越夸张。需要注意的是亮部颜色最好用原色乘强度而不是直接用白色代替否则颜色会失真。模糊部分书中用的是双Pass高斯模糊第一个Pass只做水平方向采样第二个Pass只做垂直方向采样。C#脚本这边循环调用Graphics.Blit每次把上一次的结果作为下一次的输入。循环迭代的次数就是“模糊轮数”轮数越多光晕扩散越广、越柔和。我在项目里一般控制在4到6轮超过8轮效果提升不明显耗时反而增加。3.3 Bloom参数的调优心得有一个坑我必须提出来Bloom的阈值如果设置得太接近画面平均亮度整个屏幕都会发灰发白如果太高只有极亮的小点点能产生泛光画面缺乏氛围。我的建议是“阈值保守强度激进”。就是说阈值先设置得不要太高让高光区域多一些然后用强度来控制泛光轻重。这样调起来比卡阈值容易得多。另外移动端做Bloom时要注意分辨率。直接对全屏原始分辨率做多轮模糊是很奢侈的操作。优化的办法是先降分辨率把源图降采样到二分之一或四分之一后再做模糊最后再放大叠加。这种做法的画面损失在移动端小屏幕上基本看不出来帧数却能提升不少。关于降分辨率可以用RenderTexture的GetTemporary指定降采样尺寸或者配合CommandBuffer做具体看项目取舍。4. 运动模糊记录一帧之内“动过”的痕迹4.1 运动模糊的原理运动模糊在我们的日常经验里非常常见晚上拍车流长长的灯带就是一种运动模糊快速挥动手臂手机照片里手部会有拖影。游戏里做运动模糊就是为了模拟这种视觉残留让高速移动的画面显得更流畅、更真实。实现运动模糊的方式有好几种最直接的是“累积缓存法”把前后几帧的图像混合叠加让当前帧残留上一帧的影子上。这种办法实现简单不需要额外的深度信息第12章的重点就是这条路线。缺点是画面容易出现“鬼影”就是物体快速移动后留下好几条残影像延迟摄影一样有点脏。另一种更高级的方式是“速度映射法”用深度和法线重建每个像素在屏幕空间的运动向量然后沿运动方向做多次偏移采样并加权平均。这种方式效果好但需要额外的G-Buffer或者深度法线纹理性能开销也大。市面上3A大作大多用这种。书中先讲累积缓存法是为了让初学者快速上手我建议学有余力的同学把速度映射法也了解一下这对后续理解TAA有很大帮助。4.2 累积缓存法的代码结构累积缓存法的核心思路是在C#脚本中维护两张临时RenderTexture一张存当前帧的渲染结果一张存历史的混合结果。每帧执行以下流程把当前帧画面current和上一帧结果history都传给Shader。Shader根据_BlurAmount做新旧画面的插值比如0.5表示新旧各一半0.9表示保留大量旧内容。把混合结果输出到另一张临时纹理temp然后交换引用下一帧继续用。Shader侧的关键代码片段大概长这样fixed4 frag(v2f i) : SV_Target { fixed4 current tex2D(_MainTex, i.uv); fixed4 history tex2D(_HistoryTex, i.uv); return lerp(current, history, _BlurAmount); }_BlurAmount越大拖尾越明显。实际操作时我遇到过几个问题这里单独拎出来说一是画面会逐渐变暗。原因是历史纹理的Alpha或者RGB在反复混合时没有归一化导致能量丢失。解决办法是在混合时不要对Alpha做乘法衰减或者每帧把颜色乘以一个略微大于1的系数补偿。二是出现“黑边拖影”。物体快速移动时它原来的位置会暴露背景颜色而历史缓存还残留着物体的影子于是影子会拖在背景上。这个问题的根治方式还是速度映射法或者使用深度信息做遮罩。初学阶段如果遇到这种瑕疵不必太纠结只要拖影时间短、画面动得快人眼很难注意到。三是临时纹理的释放问题。用RenderTexture.GetTemporary创建的纹理一定要记得ReleaseTemporary释放否则每帧都在创建新纹理内存会持续上涨。这是新手最容易忽略的点。4.3 运动模糊在TA面试和项目中的地位如果你准备面试TA或渲染岗位运动模糊几乎是必聊话题。面试官通常会从“你的运动模糊是怎么实现的”切入延伸到“为什么会有鬼影”“怎么避免鬼影”“你做过速度向量吗”“和TAA有什么区别”。第12章只能给你一个入门级的实现但理解它之后去啃TAA、去研究MotionVector相关的接口就不会一头雾水了。另外提一句Unity新版本里已经有后处理栈Post Processing Stack和URP的Volume框架里面内置了Motion Blur。但那只是“用”你要想在性能出问题时自己改源码、调算法还是得回到基础知识上来。第12章就是这个基础。5. 常见的坑与排查技巧5.1 效果没生效怎么排查后处理效果没生效80%的情况出在这几个地方。我按从高到低的概率排列材质没有正确赋值或者Shader没编译过。在Inspector查看材质是否显示为粉色或报错如果是先看Console报错。相机上缺少脚本挂载。OnRenderImage只有在脚本挂在相机上时才触发。OnRenderImage函数签名写错。必须是void OnRenderImage(RenderTexture src, RenderTexture dst)参数名无要求但类型不能错。在URP或HDRP下直接使用。内置管线的代码在URP里不生效需要改用RenderFeature。平台差异OpenGL系列平台UV原点在左下角DirectX在左上角。涉及边缘检测方向时屏幕上边缘检测结果会上下颠倒。书中也专门提过必要时用_ProjectionParams.x做翻转修正。我把这些整理成了一张速查表方便你直接对照现象常见原因解决方案完全没有效果脚本没挂到相机上给相机挂载MonoBehaviour脚本画面粉色/报错Shader编译失败看Console详细报错检查CGPROGRAM语法只有背景被处理相机渲染顺序问题后处理只作用于当前相机画面检查相机ClearFlags边缘方向颠倒平台UV原点差异使用_ProjectionParams.x做判断和修正画面严重变暗多次混合未归一化检查RGB和Alpha的衰减系数移动端卡顿全屏Pass过多降分辨率、减少模糊迭代次数、合并采样5.2 调试工具和工作流推荐调试后处理最怕的是“看不见中间过程”。我自学的过程中养成了一个习惯每写一个效果就先在屏幕上输出中间结果比如边缘检测时直接输出灰度图Bloom时直接输出亮部图。确认中间步骤OK了再拼回最终效果。这个思路省了我很多猜谜时间。工具方面我强烈推荐Unity的Frame Debugger。打开Window Analysis Frame Debugger能逐条查看每一帧的Draw Call也能看到Graphics.Blit是不是执行了、输入输出分别是什么。配合RenderDoc甚至可以截帧查看中间RenderTexture内容这是排查后处理问题的神器。还有一个小技巧写Shader的时候用return fixed4(uv.x, uv.y, 0, 1)直接检查UV是否正确用return fixed4(luminance, luminance, luminance, 1)检查亮度分量的分布。这种土办法在调试时比任何工具都直观。5.3 性能方面的红线后处理性能优化的核心原则是“少做全屏Pass少做高分辨率采样”。一个全屏Pass意味着GPU要对屏幕上每一个像素执行一遍Fragment Shader。在4K分辨率下这是900万像素量级的计算要是里面有循环、有多次纹理采样成本直接起飞。所以我的优化顺序是先降分辨率再降迭代次数再合并采样最后才是优化Shader内部指令数。还有一点后处理效果能合在一个Pass里做就别拆成两个Pass。比如Bloom的亮部提取和第一次模糊可以尝试合并到同一个Pass里省掉一次全屏读写。移动端上还有一个容易被忽略的点不要对颜色缓冲频繁使用ReadPixels回读CPU也不要每帧创建新的材质实例。材质尽量缓存复用RenderTexture用完立即释放。这些坏习惯在PC上看不出来在移动端就是内存和发热的双重打击。6. 从第12章到实战项目的扩展思路书读完了代码敲完了如果就此打住那充其量只是“抄作业”。真正要把第12章变成自己的东西我在实际操作中总结了几个扩展方向推荐你按顺序做一遍。第一个方向把后处理脚本改造成一个可以“链式调用”的抽象框架。比如写一个PostEffectBase基类自动处理材质创建、Shader检查、临时纹理管理具体效果脚本只要实现OnRenderImage就行。这个框架能省掉你以后无数重复代码的时间。第二个方向把Bloom和边缘检测组合起来做“漫画风格化”。先用边缘检测提取线稿叠加在场景上再做Bloom让高光区域发光。这种风格化的搭配是很多国风和二次元项目的基础你掌握原理之后很容易举一反三。第三个方向结合热词里大家常问的“Unity摄像机跟随”和“Unity游戏优化”来试炼自己。比如做一个第三人称跟随相机再给它挂上运动模糊效果。高速旋转时看看拖尾是否明显切换不同视角参数体会运动模糊和相机运动关系的微妙之处。说到底后处理最终服务的是玩家的整体视觉体验而这正是技术美术的核心价值把技术变成体验。书里最后一句话我印象很深“我们学习后处理不仅仅是为了完成一个效果更是为了学会如何分析一个效果”。这句话基本概括了TA这个岗位的日常拿到一个参考图第一反应不是“这个效果叫什么”而是“它是通过什么数学工具、什么渲染步骤实现的”。学第12章的时候多问自己几个“为什么”收获会比盲目敲代码多得多。
返回列表