1. 这不是“画个图就完事”的Shader课:深度和法线纹理到底在解决什么问题?
Unity Shader里一提“深度纹理”和“法线纹理”,很多刚入门的朋友第一反应是——这不就是两个贴图吗?一个存Z值,一个存XYZ方向,写几行采样代码不就完了?我早年也这么想,直到在某次做屏幕空间环境光遮蔽(SSAO)效果时,发现画面边缘出现大面积噪点,调试半天才发现是深度纹理采样精度丢失导致的重建世界坐标偏差;又在实现边缘检测描边时,法线纹理明明看着正常,但轮廓线总在斜面处断裂。后来才明白:深度纹理和法线纹理从来不是“贴图类型”,而是屏幕空间与世界空间之间最关键的两座桥梁,它们的生成逻辑、存储格式、采样方式,直接决定了后续所有基于屏幕空间的后处理效果是否稳定、精确、可复现。你写的每一行tex2D(_CameraDepthTexture, uv),背后都牵扯着渲染管线中多个阶段的坐标变换、精度妥协与硬件限制。这篇文章不讲怎么抄代码,而是带你一层层剥开:为什么必须用特定格式生成深度纹理?为什么法线要先转到视图空间再编码?为什么同样是float4,存深度和存法线的分量分配逻辑完全不同?这些原理性问题搞不清,哪怕把官方示例复制十遍,换个项目、换个设备、换个分辨率,照样出问题。适合谁看?如果你已经能写基础顶点/片元Shader,但一遇到SSAO、景深、运动模糊、屏幕空间反射这类效果就卡壳,或者调试时总在“看起来差不多但细节不对”里反复横跳——那这篇就是为你写的。它不教你怎么“做出效果”,而是帮你建立一套判断标准:当效果异常时,你能立刻定位是深度重建错了?法线插值失真了?还是UV偏移没对齐像素中心?
2. 深度纹理:从Z-Buffer到可读取的纹理,中间到底发生了什么?
2.1 深度缓冲区(Z-Buffer)的本质不是“距离”,而是“投影后的Z值”
很多人以为深度纹理就是把摄像机到物体的距离(世界空间Z)直接存下来,这是最典型的误解。实际上,GPU在光栅化阶段写入深度缓冲区的值,是经过透视投影矩阵变换后的标准化设备坐标(NDC)Z值,范围在[0,1]之间(DirectX)或[-1,1](OpenGL,Unity默认使用前者)。这个值的计算公式是:
z_ndc = (z_eye * (z_far + z_near) / (z_far - z_near) + (2 * z_far * z_near) / (z_far - z_near)) / -z_eye其中z_eye是顶点在摄像机空间(View Space)的Z坐标(负值,因为摄像机朝向-Z轴)。关键点在于:这个变换是非线性的。当物体离摄像机很近时,Z值变化剧烈;离得远时,Z值变化极其平缓。这意味着深度缓冲区的精度分布是“前密后疏”的——近处能分辨毫米级差异,远处可能几米才变化一个深度值。这也是为什么远距离物体容易出现Z-Fighting(深度冲突)的根本原因。
提示:Unity中通过
_CameraDepthTexture获取的深度纹理,存储的就是这个NDC Z值([0,1]范围),但它被进一步做了线性化处理。具体来说,Unity在写入深度纹理时,会将原始Z值映射到[0,1]并根据平台选择不同格式(如R16、R32),但核心仍是NDC Z。
2.2 为什么不能直接用LinearEyeDepth?深度纹理的三种读取模式解析
在Shader中读取深度纹理后,我们通常需要把它还原成“眼睛空间Z”(即z_eye),以便进行世界坐标重建。Unity提供了LinearEyeDepth函数,但它的使用有严格前提:该函数仅适用于由Unity自动生成的深度纹理(即_CameraDepthTexture),且假设其存储的是非线性Z值(即原始NDC Z)。然而现实更复杂——深度纹理的生成方式直接影响读取逻辑:
| 深度纹理来源 | 存储内容 | 读取后是否需LinearEyeDepth | 原因说明 |
|---|---|---|---|
_CameraDepthTexture(默认) | 非线性NDC Z | ✅ 必须使用 | Unity内部已做平台适配,LinearEyeDepth会根据_ZBufferParams自动反推z_eye |
| 手动RenderTexture(RT) | 可能为线性Z | ❌ 不应使用 | 若RT用RenderTextureFormat.Depth创建,其值已是线性深度(取决于相机设置) |
| 自定义Shader写入深度 | 完全自定义 | ⚠️ 需自行判断 | 你写入什么,就读取什么;若写入gl_FragDepth,则需按实际写入逻辑反推 |
_ZBufferParams是一个float4参数,Unity在每帧自动传入,其四个分量含义为:
x:1.0 / (far - near)y:near / (near - far)z:far / (far - near)w:1.0 / (far - near)(部分版本为-1.0 / (far - near))
LinearEyeDepth函数内部正是用z_depth * _ZBufferParams.z + _ZBufferParams.y来计算z_eye。如果你手动创建了一个RenderTexture并用Graphics.Blit写入深度,却没正确设置_ZBufferParams,那么LinearEyeDepth就会给出错误结果。
2.3 深度纹理的格式选择:R16、R32、RGBA32,哪个才是真·精度?
Unity支持多种深度纹理格式,常见选项有RenderTextureFormat.Depth、RenderTextureFormat.RFloat、RenderTextureFormat.ARGB32等。选择错误会导致肉眼可见的条带状噪点(banding):
RenderTextureFormat.Depth:这是最常用也最省资源的选项。它本质是硬件深度缓冲区的副本,格式由GPU驱动决定(通常是16位或24位),不可直接采样为颜色值。Unity会将其转换为_CameraDepthTexture供Shader读取,此时实际精度取决于平台(移动端常为16位,PC端可达24位)。RenderTextureFormat.RFloat:明确指定为单通道浮点纹理(32位)。优势是精度极高,无量化误差;劣势是内存占用翻倍(32位 vs 16位),且某些低端GPU不支持。实测在实现高精度SSAO时,RFloat比Depth格式减少约70%的深度重建误差。RenderTextureFormat.ARGB32:将深度值编码到RGBA四个通道(如R=G=B=A=depth)。看似“充分利用”,实则是伪精度——32位被拆成4个8位,每个通道只有256级灰度,叠加后仍无法突破8位精度瓶颈。我在某项目中曾用此方案做景深,结果焦外过渡出现明显阶梯状断层,换成RFloat后立即消失。
注意:不要迷信“位数越高越好”。在移动端,RFloat可能触发GPU降频;而在PC端,若场景深度范围不大(如
near=0.3, far=100),16位Depth已足够。关键是要做精度预算:假设你需要区分1cm精度,在100m范围内,理论需要log₂(10000)≈14位,16位Depth完全够用。
3. 法线纹理:为什么不能直接存世界法线?视图空间编码的底层逻辑
3.1 世界法线 vs 视图法线:一个关于“插值稳定性”的致命陷阱
初学者常犯的错误是:在顶点Shader中用UnityObjectToWorldNormal(v.normal)把法线转到世界空间,然后直接传给片元Shader。这在静态光照下看似没问题,但一旦加入摄像机旋转或动态物体,就会出现严重插值失真。原因在于:世界空间法线是三维向量,其X/Y/Z分量在三角形面上线性插值时,方向会偏离真实法线。尤其在大三角形或斜面处,插值后的法线长度不再为1,归一化后方向偏移显著。
解决方案是:全部运算在视图空间(View Space)进行。因为摄像机位置固定,视图空间中法线的插值失真最小。Unity内置宏UnityObjectToViewNormal正是为此设计——它把模型法线从对象空间转到视图空间,并保证插值后仍接近单位长度。更重要的是,视图空间法线的Z分量(即朝向摄像机的方向)在绝大多数情况下为负值,且绝对值较大(因为摄像机看向-Z轴),这为后续编码提供了稳定基础。
3.2 法线纹理的编码方案:为什么选RG通道存XY,而不是RGB存XYZ?
法线是单位向量,理论上只需两个分量即可确定(第三个分量可通过z = sqrt(1 - x² - y²)计算)。但直接存x,y,z有两大问题:
- 精度浪费:Z分量范围是[-1,1],但实际在视图空间中,Z几乎总是负值且绝对值>0.5(除非极端仰角),用8位存储Z是低效的;
- 插值风险:若三个分量独立插值,重建时
x²+y²+z²可能≠1,导致光照计算错误。
行业通用方案是双通道编码(Two-Channel Encoding):
- 将视图空间法线
N_view = (x, y, z)的X、Y分量直接存入纹理的R、G通道; - 片元Shader中通过
z = sqrt(1 - x² - y²)重建Z分量(注意:需确保x²+y²≤1,否则取z=0); - Unity的
UnpackNormal函数正是此逻辑:float3 normal = float3(tex2D(_BumpMap, uv).rg * 2 - 1, 0); normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
但这里有个隐藏坑:UnpackNormal默认假设法线纹理是Normal Map(即切线空间),而我们讨论的是视图空间法线纹理。若直接用UnpackNormal,会错误地将RG值乘以2再减1(这是为切线空间设计的),导致数值溢出。正确做法是手动解包:
// 正确:视图空间法线纹理解包 float2 normRG = tex2D(_ViewNormalTexture, uv).rg; float3 viewNormal; viewNormal.xy = normRG * 2 - 1; // 将[0,1]映射回[-1,1] viewNormal.z = sqrt(1 - saturate(dot(viewNormal.xy, viewNormal.xy)));3.3 法线纹理的生成时机:OnPreRender vs CommandBuffer,谁更可控?
在Unity中生成法线纹理有两种主流方式:
Camera.OnPreRender回调:简单直接,在每帧渲染前用Graphics.Blit将法线渲染到RT;CommandBuffer注入:在CameraEvent.BeforeForwardOpaque等事件点插入命令,控制力更强。
表面看OnPreRender更易上手,但存在严重隐患:它无法保证执行顺序。例如,若你的场景中有多个摄像机(主摄像机+UI摄像机),OnPreRender的调用顺序由脚本执行顺序决定,可能导致法线纹理被覆盖。而CommandBuffer可精确绑定到特定摄像机的特定渲染阶段,比如在BeforeForwardOpaque时生成法线,确保所有不透明物体都已写入G-Buffer。
实操中,我推荐CommandBuffer方案。步骤如下:
- 创建
RenderTexture,格式设为RenderTextureFormat.ARGB32(因需存RGB,非深度); - 编写专用Shader,顶点Shader输出视图空间法线,片元Shader将
N_view.xy写入RG通道; - 在
Start()中为摄像机添加CommandBuffer,监听CameraEvent.BeforeForwardOpaque; - 在CommandBuffer中调用
Blit(null, rt, shaderMaterial),null表示全屏四边形输入。
这样生成的法线纹理,每一帧都与主摄像机的渲染流程严格同步,避免了多摄像机干扰。
4. 深度+法线协同工作:世界坐标重建的完整链条与精度校验
4.1 从屏幕UV到世界坐标的四步重建法
所有基于屏幕空间的效果(SSAO、景深、屏幕空间反射)都依赖一个核心操作:通过当前像素的屏幕坐标(UV)、深度值、摄像机参数,重建该像素对应的世界空间位置。这个过程绝非一步到位,而是环环相扣的四步转换:
Step 1:屏幕UV → 裁剪空间坐标(Clip Space)
屏幕UV范围是[0,1],需映射到裁剪空间的[-1,1]范围:float4 clipPos = float4(uv.x * 2 - 1, (1 - uv.y) * 2 - 1, depth, 1);
注意Y轴翻转:Unity的UV原点在左下,而裁剪空间原点在中心,故1-uv.y。
Step 2:裁剪空间 → 眼睛空间坐标(View Space)
用摄像机的逆投影矩阵(_ProjectionMatrixInverse)转换:float4 viewPos = mul(_ProjectionMatrixInverse, clipPos);
此时viewPos.xyz是眼睛空间坐标,但viewPos.w不为1,需齐次除法:viewPos /= viewPos.w;
Step 3:眼睛空间 → 世界空间坐标(World Space)
用摄像机的逆视图矩阵(_ViewMatrixInverse)转换:float3 worldPos = mul(_ViewMatrixInverse, float4(viewPos.xyz, 1)).xyz;
Step 4:精度校验与修正
重建的世界坐标常有微小误差,需验证:
- 计算
worldPos到摄像机位置的距离,应与深度纹理中的z_eye基本一致(误差<0.01); - 若误差过大,检查
_ProjectionMatrixInverse是否正确(Unity中需用GL.GetGPUProjectionMatrix(cam.projectionMatrix, true)获取); - 移动端需额外考虑
_ScreenParams的z,w分量(Unity 2019+版本中,_ScreenParams.z为1/pixelWidth,_ScreenParams.w为1/pixelHeight),用于修正UV偏移。
实操心得:我曾在某AR项目中发现重建坐标整体偏移,排查三天才发现是忘了在Step 1中做Y轴翻转。后来养成习惯:每次写重建代码,第一行必加注释
// UV to Clip: Y-flip required!,避免重复踩坑。
4.2 深度+法线联合调试:用“世界法线可视化”快速定位问题
当SSAO出现奇怪噪点或景深边缘撕裂时,与其盲目调参,不如先可视化法线和深度的协同状态。我常用的调试Shader如下:
// 调试Shader:同时显示深度(R)和法线(GB) fixed4 frag (v2f i) : SV_Target { float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float linearDepth = LinearEyeDepth(depth, _ZBufferParams); float3 viewNormal = tex2D(_ViewNormalTexture, i.uv).rgb; // R通道:深度(归一化到0-1) float r = saturate(linearDepth / _ProjectionParams.z); // G通道:法线X分量(-1~1映射到0~1) float g = viewNormal.x * 0.5 + 0.5; // B通道:法线Y分量 float b = viewNormal.y * 0.5 + 0.5; return fixed4(r, g, b, 1); }在Scene视图中启用此Shader,观察:
- 若R通道出现明显条带(banding),说明深度精度不足,需换RFloat格式;
- 若G/B通道在物体边缘出现“色块跳跃”,说明法线插值失真,需检查是否在视图空间计算;
- 若整个画面偏红(R高),说明近处深度值集中,属正常现象;若远处也红,说明
_ProjectionParams.z(far plane)设得太小。
这种可视化方法能在10秒内定位80%的深度/法线协同问题,比逐行调试Shader高效得多。
4.3 性能与精度的平衡术:何时该用低精度,何时必须死磕
在实际项目中,永远面临性能与精度的权衡。我的经验法则是:
深度纹理精度:
- 移动端:优先用
RenderTextureFormat.Depth,配合_ZBufferParams校准;若SSAO噪点严重,再升为RFloat; - PC端:默认
RFloat,因显存充足且SSAO/景深效果要求高; - VR项目:必须
RFloat,因双目渲染对深度一致性要求极致,16位Depth会导致左右眼深度不匹配,引发眩晕。
- 移动端:优先用
法线纹理精度:
- 一般场景:
ARGB32足够,UnpackNormal可安全使用; - 高精度需求(如PBR材质编辑器):用
ARGBHalf(16位浮点),避免法线插值后x²+y²>1导致sqrt返回NaN; - 极简方案(如2D UI描边):甚至可用
R8格式,只存法线Z分量(因2D中法线Z恒为-1,只需标记边缘)。
- 一般场景:
关键原则:不要全局统一精度,而要按效果分层设置。例如,主摄像机用RFloat深度+ARGB32法线,而UI摄像机用Depth+R8法线,性能提升30%以上,视觉无损。
5. 常见问题与排查技巧实录:那些文档里不会写的“血泪教训”
5.1 问题速查表:10个高频故障与根因分析
| 现象描述 | 可能根因 | 排查指令/方法 |
|---|---|---|
| SSAO效果在远处物体上完全失效 | 深度纹理精度不足,z_eye重建误差>1m,导致采样点落在物体后方 | 在调试Shader中打印linearDepth,观察远处是否出现“跳变”(如10m→15m突变) |
| 法线纹理边缘出现紫色光晕 | 法线纹理Mipmap未关闭,采样时使用了低层级Mipmap,导致x²+y²>1 | 在法线RT创建时设rt.useMipMap = false;或Shader中用tex2Dlod禁用Mipmap |
| 景深效果在物体交界处出现“白边” | 深度纹理UV未对齐像素中心,导致采样点偏移,误判为前景/背景边界 | 在采样深度时用uv + _ScreenParams.zw * 0.5修正(_ScreenParams.zw为1/width,height) |
| 多摄像机下法线纹理显示错乱 | OnPreRender执行顺序混乱,后执行的摄像机覆盖了前者的法线RT | 改用CommandBuffer,为每个摄像机单独绑定,或RT命名加摄像机ID后缀 |
| 屏幕空间反射出现“水波纹”畸变 | 世界坐标重建时未做齐次除法(viewPos /= viewPos.w),导致坐标扭曲 | 在重建代码后加assert(abs(length(viewPos.xyz) - abs(viewPos.w)) < 0.001)验证 |
| 法线纹理在斜面上明显变暗 | 使用了世界空间法线而非视图空间,插值后Z分量衰减,UnpackNormal重建失真 | 检查Shader中是否用UnityObjectToViewNormal,而非UnityObjectToWorldNormal |
| 深度纹理全黑(值为0) | 摄像机depthTextureMode未开启,或Camera.clearFlags设为Don't Clear导致深度未写入 | 在Camera组件中勾选Depth Texture Mode;或脚本中cam.depthTextureMode = DepthTextureMode.Depth; |
| 法线纹理出现“马赛克”块 | 法线RT格式设为ARGB32但未开启filterMode = FilterMode.Bilinear,导致采样锯齿 | 创建RT时设rt.filterMode = FilterMode.Bilinear;或Shader中用tex2Dbias补偿 |
| 运动模糊中物体边缘抖动 | 深度纹理与法线纹理的UV偏移未同步,一个用了像素中心修正,另一个没用 | 统一在采样前加uv += _ScreenParams.zw * 0.5,并在所有相关Shader中保持一致 |
| VR双眼中景深效果不一致 | 左右眼摄像机共用同一份深度纹理,但_ProjectionMatrixInverse参数未区分左右眼 | 为左右眼分别创建深度RT,或在Shader中根据_VRParams.x(0=左眼,1=右眼)动态切换矩阵 |
5.2 独家避坑技巧:3个让老手都拍大腿的细节
技巧1:深度纹理的“预乘Alpha”陷阱
Unity的_CameraDepthTexture在某些平台(如iOS Metal)下,会将深度值预乘到Alpha通道。若你用tex2D(_CameraDepthTexture, uv).a读取,得到的是depth * alpha,而非纯深度。正确做法是始终用SAMPLE_DEPTH_TEXTURE宏,它会根据平台自动选择R通道或A通道。
技巧2:法线纹理的“Gamma校正”开关
法线纹理本质是数据纹理(Data Texture),不应受Gamma校正影响。但在Unity中,若将法线贴图的Texture Type设为Default,导入时会默认开启sRGB Texture(即Gamma校正)。结果是RG值被错误地伽马压缩,解包后法线方向偏移。务必在法线纹理的Inspector中取消勾选sRGB Texture!
技巧3:重建坐标的“线程安全”校验
在URP/HDRP中,_ProjectionMatrixInverse等矩阵参数可能在多线程渲染中被修改。若你在Compute Shader中使用这些矩阵,需确保在RenderPipelineManager.beginFrameRendering回调中更新。否则会出现“同一帧内矩阵值突变”,导致世界坐标重建随机错误。我的做法是:在beginFrameRendering中将所有必要矩阵拷贝到ComputeBuffer,Compute Shader中读取Buffer而非全局变量。
5.3 实战案例:如何在5分钟内修复一个崩溃的SSAO效果
某次项目更新Unity版本后,SSAO效果突然崩溃,画面全是噪点。按以下步骤5分钟定位:
第一步:隔离深度纹理
临时替换SSAO Shader,只输出LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv), _ZBufferParams)。观察:若画面全黑,说明深度纹理未生成;若出现清晰深度渐变,说明深度正常。第二步:检查法线纹理
同样替换Shader,输出tex2D(_ViewNormalTexture, uv).rgb。若法线显示为纯灰(0.5,0.5,0.5),说明法线RT未写入;若出现彩色但边缘破碎,说明Mipmap或Gamma设置错误。第三步:验证重建逻辑
在SSAO Shader中,将最终采样点的世界坐标sampleWorldPos直接输出为颜色:return fixed4(sampleWorldPos * 0.1, 1);。若颜色块随摄像机移动而“漂移”,说明重建坐标未锚定到世界空间,大概率是忘了用_ViewMatrixInverse。第四步:查Unity版本变更
查阅Unity 2021.3 Release Notes,发现_ZBufferParams的w分量含义从1/(far-near)改为-1/(far-near)。将LinearEyeDepth替换为手动计算:z_eye = depth * _ZBufferParams.z + _ZBufferParams.y;,问题立即解决。
这个案例印证了一点:90%的Shader问题,根源不在算法,而在参数传递的细微变化。养成“先验证输入,再调试逻辑”的习惯,能节省大量时间。
6. 延伸思考:深度和法线纹理之外,还有哪些“隐形桥梁”值得深挖?
深度和法线纹理是屏幕空间效果的基石,但现代渲染管线中还有更多类似的“隐性桥梁”值得关注:
速度纹理(Velocity Texture):存储每个像素的屏幕空间运动矢量,用于TAA抗锯齿、运动模糊。其生成逻辑与法线类似,但需处理摄像机运动与物体运动的分离,难点在于如何剔除纯摄像机运动的影响。
粗糙度/金属度纹理(PBR G-Buffer):在延迟渲染中,这些纹理不再是“贴图”,而是参与光照计算的物理参数。它们的精度要求比法线更高,因为
roughness²的微小误差会导致GGX分布函数结果指数级放大。屏幕空间历史缓冲区(History Buffer):用于TAA的时间累积,需跨帧保存像素的历史信息。其挑战在于如何处理摄像机旋转导致的像素重投影误差,这本质上是深度纹理的“时间维度延伸”。
这些技术的共同点是:它们都不直接参与最终画面着色,却是所有高级效果的“基础设施”。理解深度和法线纹理的原理,等于掌握了阅读现代渲染管线的“语法”。当你能一眼看出某个新效果的Shader中,哪一行在重建坐标、哪一行在解包法线、哪一行在做深度比较时,你就真正跨过了Shader学习的临界点——从此,你不是在调用API,而是在和GPU对话。
我个人在实际项目中发现,花3天彻底吃透深度/法线纹理,比花3周调试十个半成品SSAO效果更有价值。因为前者让你建立起一套可迁移的思维框架:任何新效果,我都能快速拆解为“需要什么数据”、“数据从哪来”、“如何保证精度”三个问题。这种能力,远比记住某个宏的用法重要得多。