Unity Shader后处理实现SSAO:从深度纹理到环境光遮蔽全解析
做Unity Shader最爽的一件事就是当你把一个看似高深的名词拆开发现背后逻辑其实就那么几行代码的时候。SSAOScreen Space Ambient Occlusion屏幕空间环境光遮蔽绝对算一个。它没有复杂的光线追踪也没有预烘焙流程完全靠一张深度图和一个采样循环就能让画面暗角、物体相接处、缝隙这些细节立刻“沉”下去。这篇文章我打算用一套可以直接跑的Unity示例来拆解SSAO从深度法线获取、观察空间重建、半球采样核到双边模糊和调参把每一步为什么这么做讲清楚。适合已经写过简单Shader、对相机和RenderTexture有基本概念但没碰过后处理的开发者。看完之后你不仅能在Built-in管线里复现一个SSAO也会知道迁移到URP时要动哪些地方。1. SSAO到底在解决什么问题那些“悬浮”的物体与缺失的暗部细节1.1 环境光遮蔽的物理来源角落为什么是暗的假设一个墙角环境光从四面八方照过来。在墙角这个凹进去的区域有一半方向的入射光被墙面挡住了所以到达这里的间接光能量天然比开阔平地少。这就是“环境光遮蔽”的物理本质不是物体自己发了阴影而是它周围几何体挡住了环境光。你如果在游戏里把AO关掉画面最明显的问题不是曝光而是物体之间缺少“接触感”。角色站在地面脚底和地面之间像隔了一层薄光墙角、缝隙、褶皱全是平的立体感立刻弱掉。烘焙AO就是美术在模型上预先算好一张“哪里暗”的贴图录制进UV里。这种方式效果不错但只能覆盖静态场景动态物体一移动遮蔽信息就失效了而且美术每改一次模型就要重新烘焙一次迭代成本高。1.2 烘焙AO与实时SSAO的取舍SSAO思路完全不同它不关心模型长什么样只关心最终画面上的深度缓冲。它把相机看到的内容当成一个“屏幕空间几何体”挨个像素检查它周围有没有其他像素挡着。换句话说AO的计算对象从模型变成了最终的屏幕图像所以动态物体天然支持不需要额外贴图也不需要美术介入。代价也很明显屏幕空间只能看到表面信息那些藏在深度缓冲背后的几何体是感知不到的。比如一个箱子后面有个球球虽然在场景里遮挡了墙但它没有出现在深度缓冲中SSAO就完全不知道它存在。这是所有屏幕空间技术的天花板不只在AO上。做SSAO的时候心里要有这根弦它是“屏幕上的估算”不是真实GI。1.3 SSAO值得学的三件事从学习角度SSAO是一道很完整的图形学后处理入门题。它本身就要用到深度缓冲、法线重建、坐标变换、随机采样、后处理模糊、RenderTexture切换这些基础操作几乎一个项目覆盖了一整片知识点。而且它的效果反馈特别直观参数调大了画面变暗调小了看不见采样数增加噪点减少但帧率下降。你可以非常清晰地感受到“算法参数”和“渲染结果”之间的联动这种正反馈比闷头读渲染管线文档要好得多。2. 从深度和法线开始SSAO需要的“屏幕资产”2.1 拿到深度与法线的三种姿势SSAO的第一步不是算遮蔽而是从画面里“读出”每个像素的位置和朝向。Unity提供了现成的机制你只要在Camera上设置深度纹理模式Camera cam GetComponentCamera(); cam.depthTextureMode | DepthTextureMode.Depth; cam.depthTextureMode | DepthTextureMode.DepthNormals;设置之后Shader里两个全局纹理就会自动可用_CameraDepthTexture存储每个像素的原始深度值范围是非线性的直接读出来不能当距离用。_CameraDepthNormalsTexture把深度和法线打包在一张RGBA纹理里需要用UnityCG.cginc里的工具函数解包。动手写SSAO之前我建议你先做一个小实验写一个最简单的后处理Shader把这两个纹理可视化到屏幕上。深度图应该是一张从近到远的灰度渐变法线图应该是一张颜色随朝向变化的彩色图。这一步能帮你确认相机的深度模式是否生效也能让你直观感受“屏幕资产”长什么样。2.2 重建观察空间位置的数学细节有了深度纹理我们还要把每个像素的深度值换回真实的三维坐标。这里涉及一个关键选择到底在哪个空间算SSAO我强烈建议在**观察空间View Space**计算。原因有两点第一观察空间的深度就是相机到物体的真实距离LinearEyeDepth(rawDepth)可以直接拿到少一次世界坐标的矩阵运算第二_CameraDepthNormalsTexture里解出来的法线本来就是观察空间法线如果硬要转到世界空间反而多此一举。重建观察空间位置的经典做法是在顶点阶段算一条“射线方向”到片元阶段再用深度把它拉长。全屏后处理的顶点Shader在观察空间里其实是一个放在近裁剪面的四边形它的顶点坐标经过投影矩阵反推刚好能表达“从相机出发、穿过这个像素的视线方向”。struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 viewRay : TEXCOORD1; }; v2f vert(appdata_img v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; float2 ndc v.uv * 2.0 - 1.0; #if UNITY_UV_STARTS_AT_TOP ndc.y -ndc.y; #endif float4 vp mul(UNITY_MATRIX_P, float4(ndc, 1.0, 1.0)); o.viewRay vp.xyz / vp.w; return o; }在片元Shader里你只需要把视线方向乘以线性深度就能得到观察空间坐标float rawDepth tex2D(_CameraDepthTexture, uv).r; float linearDepth LinearEyeDepth(rawDepth); float3 viewPos i.viewRay * linearDepth;这个技巧的数学背景是在观察空间里相机位于原点物体方向恰好与视线方向一致所以位置 方向 × 距离。要比在片元里做逆投影矩阵运算快不少而且没有矩阵求逆的精度波动。2.3 法线为什么不能简单照抄很多初学者拿到_CameraDepthNormalsTexture就直接*2-1解包结果发现法线颜色怪怪的。原因是Unity对法线做了带符号编码而且为了节省空间法线的z分量可能不是直接存进去的而是通过sqrt(1 - dot(xy, xy))重建出来的。如果只做简单的线性解码z分量会偏掉导致后面采样方向错误。正确姿势是直接用内置函数float3 viewNormal DecodeViewNormalStereo(tex2D(_CameraDepthNormalsTexture, uv));这个函数会把存储的xy法线展开成完整的三分量法线方向指向观察空间。需要注意在某些渲染管线版本里它的函数名和写法有细微差异但基本原理是一样的法线要从编码格式里正确解出来而不是当普通颜色用。2.4 用一次小实验验证数据可用拿到viewPos和viewNormal之后别急着往下写采样循环。先做一次“伪SSAO”验证把AO结果临时替换成viewNormal.x * 0.5 0.5输出屏幕上应该出现左右渐变再替换成linearDepth / far输出应该有近远渐变。如果这些都正常说明你的深度模式、坐标重建、法线解码这一整条链路是通的。我见过不少同学花很长时间调SSAO效果最后发现是深度纹理模式没开或者UV翻转写反了导致采样位置错位整个画面花掉。数据验证这一步省不得。3. 遮蔽值计算的核心流程采样核、切线空间与深度比较3.1 半球采样核的构造与权重倾向SSAO真正干的事情是对每个像素周围的半球空间做采样。核心思想是以当前像素为圆心沿着法线方向生成一个半球在这个半球内撒N个采样点然后判断这些采样点是不是被场景挡住了。挡住越多说明这个像素越“闭塞”。采样核的生成一般不在Shader里做而是在C#端生成一组固定向量然后通过数组传入Shader。生成时需要两个技巧一是所有方向要落在法线一侧所以z分量必须偏向正方向二是采样点半径不能均匀分布要“近密远疏”。近处采样点负责捕捉细微褶皱远处采样点负责感受大范围的遮蔽趋势两者权重不一样。Vector3[] kernel new Vector3[64]; for (int i 0; i kernel.Length; i) { Vector3 dir new Vector3( Random.Range(-1f, 1f), Random.Range(-1f, 1f), Random.Range(0f, 1f)); dir.Normalize(); float scale (float)i / kernel.Length; scale Mathf.Lerp(0.1f, 1f, scale * scale); kernel[i] dir * scale; } ssaoMat.SetVectorArray(_SampleKernel, kernel);注意scale * scale这一行它让后面的采样点半径逐渐增大这样近处的采样点更密集符合AO的物理特性——越靠近遮蔽物遮蔽衰减越明显。3.2 把采样点投影回屏幕和深度较劲拿到采样方向后并不是直接在世界空间里算距离而是要把采样点投回屏幕空间去采样深度纹理然后比较“采样点深度”和“场景深度”。这个逻辑很巧妙。想象当前像素P沿着它的法线方向偏移生成采样点S。如果S位于某个场景表面后面那么从相机角度看S不可见——深度纹理里记录的那一层的深度值会比S更小。于是我们可以说S被遮挡了。用观察空间正数深度来表述会更清晰float3 samplePos viewPos mul(TBN, kernelDir) * _AORadius; float sampleViewDepth -samplePos.z; // 观察空间z为负取负后变成正深度 float4 clipPos mul(UNITY_MATRIX_P, float4(samplePos, 1.0)); float2 sampleUV clipPos.xy / clipPos.w * 0.5 0.5; #if UNITY_UV_STARTS_AT_TOP sampleUV.y 1.0 - sampleUV.y; #endif float sampleRawDepth tex2D(_CameraDepthTexture, sampleUV).r; float sampleLinearDepth LinearEyeDepth(sampleRawDepth); float occlusion 0.0; if (sampleLinearDepth sampleViewDepth - _AOBias) occlusion 1.0;这段代码里的TBN矩阵负责把采样核从“法线正上的局部空间”旋转到“当前像素自己的朝向空间”。如果少了这一步所有像素都会用同一个方向采样AO结果会是一块块规律的假阴影毫无可用性。_AOBias是一个很微小的偏移量用来抵消深度比较时因为精度误差引起的自遮挡。没有它物体表面会出现一层细细的“灰边”边缘又脏又闪。3.3 随机噪声用4x4纹理打散带状伪影如果采样数比较少比如16个那么所有像素使用同一个采样核时会因为采样方向的规律性产生明显的带状条纹banding。这就是为什么经典SSAO都要配一张随机噪声纹理。做法是生成一张很小的4x4纹理每个像素存一个随机方向然后在Shader里让屏幕UV平铺成多个4x4块。这样每四个像素就会用不同的旋转基对采样核做一次旋转破坏规律性条纹就被打散成随机噪点。Texture2D noiseTex new Texture2D(4, 4, TextureFormat.RGBA32, false); for (int y 0; y 4; y) { for (int x 0; x 4; x) { Vector3 dir new Vector3( Random.Range(-1f, 1f), Random.Range(-1f, 1f), 0).normalized; noiseTex.SetPixel(x, y, new Color(dir.x, dir.y, 0, 0)); } } noiseTex.Apply(); noiseTex.wrapMode TextureWrapMode.Repeat;Shader端读取时要把UV按屏幕大小除以4放大float3 randomVec tex2D(_NoiseTex, uv * (_ScreenParams.xy / 4.0)).xyz;随机噪点本身会给AO结果带来高频噪声所以下一章的双边模糊是必不可少的一步。噪声的意义在于把“规律条纹”换成了“高频噪点”高频噪点更容易被过滤而规律条纹过滤不掉。这算是图形学里一个很典型的“把问题换一种更难察觉的形式”的思路。3.4 完整的AO Pass代码与参数含义把上面这些拼起来核心AO Pass大概是这样的fixed4 fragAO(v2f i) : SV_Target { float rawDepth tex2D(_CameraDepthTexture, i.uv).r; float linearDepth LinearEyeDepth(rawDepth); float3 viewPos i.viewRay * linearDepth; float3 viewNormal DecodeViewNormalStereo(tex2D(_CameraDepthNormalsTexture, i.uv)); float3 randomVec tex2D(_NoiseTex, i.uv * _ScreenParams.xy / 4.0).xyz; float3 tangent normalize(randomVec - viewNormal * dot(randomVec, viewNormal)); float3 bitangent cross(viewNormal, tangent); float3x3 TBN float3x3(tangent, bitangent, viewNormal); float occlusion 0.0; for (int s 0; s _SampleCount; s) { float3 dir mul(TBN, _SampleKernel[s].xyz); float3 samplePos viewPos dir * _AORadius; float sampleViewDepth -samplePos.z; float4 clipPos mul(UNITY_MATRIX_P, float4(samplePos, 1.0)); float2 sampleUV clipPos.xy / clipPos.w * 0.5 0.5; #if UNITY_UV_STARTS_AT_TOP sampleUV.y 1.0 - sampleUV.y; #endif float sampleLinearDepth LinearEyeDepth(tex2D(_CameraDepthTexture, sampleUV).r); if (sampleLinearDepth sampleViewDepth - _AOBias) occlusion 1.0; } float ao 1.0 - saturate(occlusion / (float)_SampleCount * _AOIntensity); ao pow(ao, _AOPower); return float4(ao, ao, ao, 1.0); }这里的_AOIntensity控制遮蔽强度的缩放_AOPower控制衰减曲线。两者配合可以调出从“轻微接触阴影”到“粗犷油画风”的各种风格。默认情况下_AORadius设为0.3~0.6之间比较安全太大会跨过物体轮廓产生大面积误遮蔽。4. 脏噪点的处理双边模糊与画面合成4.1 为什么AO中间结果总是又脏又花运行AO Pass之后你会得到一张灰蒙蒙的图它充满了颗粒感。原因有两个一是随机噪声纹理让相邻像素的采样旋转不同所以AO值在局部剧烈跳动二是采样点数量不足每个像素对遮蔽的判断本来就带有很强的随机性。如果你尝试直接用这张图合成画面会有明显的“粗砂粒”感而且动画播放时颗粒还会闪烁非常难看。所以要加一道模糊。普通高斯模糊会把边缘也糊掉AO的“边缘”恰恰是最重要的信息物体轮廓两侧一边遮蔽很重、一边很亮如果糊成一团轮廓立体感就毁了。所以必须用双边模糊模糊时不仅看空间距离还要看深度距离深度差大的像素不参与混合。4.2 双边模糊的具体权重怎么写双边模糊的核心是“深度相近才算邻居”。实现上我们循环采样周围像素时计算每个像素与中心像素的线性深度差深度差越小权重越高。最粗暴但有效的写法是做一个硬阈值判断float centerDepth LinearEyeDepth(tex2D(_CameraDepthTexture, uv).r); float totalWeight 0.0; float result 0.0; for (int x -2; x 2; x) { for (int y -2; y 2; y) { float2 offsetUV uv float2(x, y) * _MainTex_TexelSize.xy; float w 1.0; float neighborDepth LinearEyeDepth(tex2D(_CameraDepthTexture, offsetUV).r); if (abs(neighborDepth - centerDepth) _BlurDepthTolerance) w 0.0; result tex2D(_MainTex, offsetUV).r * w; totalWeight w; } } return result / totalWeight;_BlurDepthTolerance就是一个深度阈值。设得太大边缘保护效果减弱设得太小角落里很多像素被排除模糊会变成“马赛克”。我一般从0.3开始调在看明显物体轮廓的前提下尽量让AO变干净。如果觉得硬阈值太硬可以用连续的高斯权重w exp(-(depthDiff * depthDiff) / (2 * sigma * sigma))但实际游戏开发中硬阈值适度采样点数量已经够用了。4.3 合成到原图时别犯的乘法错误模糊完成之后AO图是一张灰度图亮的地方遮蔽少暗的地方遮蔽多。合成时很多新手会直接把整张原图乘以AO值float ao tex2D(_AOTex, uv).r; return float4(color.rgb * ao, 1.0);这在视觉上确实变暗了但不物理。因为环境光遮蔽影响的是间接光照直接光不应该被打折。假如场景里有一盏很强的平行光SSAO把整个受光面都压暗画面会显得很脏。一个稍微合理一点的做法是把AO作用在“环境光分量”上。如果你用的是简单的环境光模型可以这么拆float3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb; float3 direct color.rgb - ambient; color.rgb direct ambient * ao;当然在没有任何间接光数据的后处理阶段很难做到严格物理。大多数游戏项目都是直接乘一个(1 - (1 - ao) * _AOBlend)类似的混合系数把AO效果控制在很轻的范围内。调参时记住一个原则SSAO应该是“锦上添花”不是“阴影贴图”。如果画面暗部已经影响了主光源的方向判断说明强度太高了。5. 实践中容易踩的坑与调参路线5.1 深度精度、自遮挡与“光晕”问题深度缓冲的精度是SSAO最大的敌人。相机的Near和Far如果跨度太大深度值会集中在很小的范围里导致远处物体的深度比较误差巨大。观察空间深度虽然经过LinearEyeDepth变成了线性但原始深度纹理的存储精度依然受限于Near/Far的比例。如果你发现远距离物体上出现大面积的错误遮蔽第一件事不是调采样参数而是检查相机Near是不是设置得太小了。自遮挡Self-Occlusion则是另一个高频问题。当成千上万个采样点围绕表面做半球采样时总有一部分采样点因为深度精度误差而被误判为“被遮挡”导致整个表面出现一层均匀的灰色。解决手段就是_AOBias。这个值太小没用太大会把真实遮蔽也消掉。我通常从0.025开始调然后配合_AOIntensity一起微调。“光晕”Halo常常出现在物体轮廓外侧轮廓外本来不该被遮蔽但因为采样核半径跨过了轮廓导致遮蔽信息“漏”到了背景上。这种问题一般靠缩小_AORadius缓解或在模糊阶段加强深度边缘保护。5.2 参数调节地图先从哪个值下手如果你在电脑前对着一个效果图无从下手可以参考下面这个调节顺序参数作用调节建议_AORadius遮蔽范围半径越大暗部扩散越远从0.3开始先确定遮蔽“长什么样”_SampleCount采样数量影响噪点与性能先用32看效果最后再考虑降到16_AOBias自遮挡补偿抑制表面灰蒙画面发灰就加轮廓丢失就减_AOIntensity总强度最终覆盖程度效果太猛就降到0.8~1.0_AOPower遮蔽的对比度让暗部更暗数值越大暗部越深亮部越亮个人经验是先把_AORadius和_AOBias调稳让遮蔽区域的位置正确再动_AOIntensity和_AOPower调整视觉强度。反过来调会比较痛苦因为位置不对的情况下你永远不知道是半径问题还是强度问题。5.3 迁移到URP的几个改动点现在新项目基本都跑URPSSAO的写法要做几个改动。首先URP里后处理不再用OnRenderImage而是通过ScriptableRendererFeatureCustomRenderPass来执行Blit操作。其次URP的深度纹理需要通过DepthNormals相关的纹理ID获取很多情况下_CameraDepthNormalsTexture不再自动可用你需要在RenderPass里显式获取CameraDepthAttachment或DepthNormalsTexture。法线解码函数也不一样。URP提供了DecodeScreenSpaceNormal之类的内置函数输入和输出语义更明确。代码本身不需要大改主要是围绕“如何插进Renderer管线”和“如何获取深度纹理”这两处要切换思路。5.4 性能优化方向半分辨率与时域复用SSAO的采样循环是纯数学计算每个像素的采样点数量直接决定GPU开销。一个非常通用的优化套路是把AO计算放在半分辨率下做。具体做法是把渲染图像缩小一半在这张半分辨率图上做采样和遮蔽计算最后模糊时再把AO图上采样回全分辨率合成。这样做视觉质量损失很小但采样开销直接砍半。前文的C#脚本里我用的RenderTexture.GetTemporary(w / 2, h / 2)就是这个思路。进一步优化可以做时域复用把上一帧的AO结果保存下来当前帧的采样核换成另一套随机序列然后新旧帧之间做混合。这样等于把每帧的采样数摊到多帧里去能大幅减少单帧采样数。不过时域复用会带来拖影问题需要配合运动检测复杂度会明显上升属于SSAO进阶方向。补充一个Unity内置的现成方案URP 12以上版本里已经内置了SSAO Renderer Feature直接勾选就能用。把它作为参考基准来对比你自己的实现效果和性能是很有价值的调试方法。最后补一句SSAO实现起来不算难真正的难点在参数调试和边缘情况处理。我第一次跑通的时候AO图灰蒙蒙一片以为自己采样写错了其实是_AOBias太小加上噪声纹理没生效。后来我把AO结果直接可视化在屏幕上一遍遍调_AORadius和_AOBias看着遮蔽区域慢慢贴合到墙角、椅子缝、角色脚底那种感觉很值得体验。如果你想向下一个阶段进阶可以尝试把采样核从均匀分布改成泊松盘分布或者把简单的半球采样换成基于法线锥的HBAO思路但无论走向哪种方案今天这套深度、法线、采样、模糊的处理链路都是最底层的地基。先把地基踩实后面的路会顺很多。