深度纹理与法线纹理:基于几何信息的屏幕后处理实战解析
2026/9/19 9:15:00 网站建设 项目流程

翻到《Unity Shader 入门精要》第13章的时候,我刚从第12章的屏幕后处理里缓过来。当时以为自己已经掌握了 Blit 和边缘检测,结果往下读才发现,前面用颜色信息做的效果,充其量是“看图说话”。真正让画面拥有空间感、能感知物体距离和表面朝向的,是深度纹理和法线纹理这两张看不见的底片。这章我前后读了三遍,每种示例都写成测试 Shader 跑过一遍,才敢说自己勉强入了门。这篇笔记适合已经能写简单屏幕后处理 Shader 的人,也适合正在做技术美术方向、想把渲染基础补扎实的读者。如果你刚学完第12章却对“为什么要用深度来检测边缘”感到困惑,那这篇应该能帮你省掉大量弯路的试错成本。

1. 为什么《入门精要》第13章值得反复读三遍

1.1 颜色纹理回答“长什么样”,深度纹理回答“离多远”

普通纹理是渲染流程里最容易被理解的资源:每个 texel 存颜色,采样后直接显示。但游戏画面里有很多信息,是颜色纹理根本表达不了的。比如一个物体被墙挡住,屏幕空间里你只知道墙上某个像素是红色,并不知道它后面还有一个角色;再比如景深效果需要知道某个像素离相机多远,靠 RGB 值不可能算得出来。

深度纹理解决的就是这种“几何可见性”问题。GPU 在光栅化阶段为了做遮挡剔除,本身就维护了一个深度缓冲,也就是每个像素当前最前面物体的深度。第13章做的事情,本质上就是把 GPU 内部这个“底片”拿出来给 Shader 用。你一旦能拿到它,就能判断屏幕上的任意一个点距离相机多远,进而实现雾效、描边、景深、软粒子、被遮挡提示等一堆效果。

颜色纹理和深度纹理的关系,可以类比成画家画一张速写:颜色纹理是颜料和笔触,深度纹理是透视的结构草稿。没有草稿,颜料涂得再好看,构图也可能是错的。

1.2 法线纹理告诉Shader“表面朝哪”

深度纹理给出了距离,但距离相同并不意味着表面朝向相同。一面墙和一根柱子在屏幕空间中可能深度相近,但墙是平的,柱子是弯的。法线纹理此时就是补充“朝向”这个信息的。

法线纹理通常把每个像素的法线向量编码到 RGBA 通道里,Shader 采样后解码成三维向量。屏幕后处理用得比较多的是屏幕空间法线,也就是“当前摄像机视角下每个像素的表面朝向”。这项数据配合深度,能非常敏感地捕捉几何轮廓:深度剧烈变化是硬边缘,深度变化不大但法线突然转向,往往就是软边缘,比如球体轮廓、棱角转折。

这也是为什么第13章会同时讲“深度纹理”和“法线纹理”。单独用深度,容易把位于同一个平面上的贴图花纹误判成边缘;单独用法线,又容易漏检那些法线连续但深度断裂的地方。两者配合,边缘检测才真正意义上从“颜色差”进化成了“几何差”。

1.3 第13章在整条学习链里卡在哪个位置

从整本书的节奏来看,第13章是前12章知识的一个“拐点”。第5、6、7章讲的是基础光照和纹理;第10章开始接触高级纹理;第12章进入屏幕后处理,教你用Graphics.Blit把整张画面交给 Shader 二次加工。但这些内容都还在“用颜色信息算效果”的范畴里。

第13章第一次把摄像机内部的深度、法线信息开放给你。没有这个基础,第14章的非真实感渲染、第15章的噪声效果、第16章的渲染优化,理解起来都会很吃力。比如第14章要做描边效果,最稳的做法就是基于深度法线纹理做边缘检测;第16章谈优化,也避不开对深度缓冲和相机深度纹理的带宽分析。

所以我把这章称为“万能钥匙”:它连接的不仅仅是几个屏幕后处理效果,更是后面所有“需要理解场景几何信息”的进阶功能的入口。

2. 深度纹理:一张非线性距离图,藏着透视投影的秘密

2.1 depth buffer 是什么时候写进去的

要理解深度纹理,先要理解 depth buffer。GPU 在渲染不透明物体时,每个像素在片元着色器执行后会拿来和已有深度缓冲做比较。如果更靠近相机,就通过深度测试并覆盖颜色和深度;如果更远,就直接丢弃。

这套机制保证了不透明物体之间正确的遮挡关系。第13章里说的_CameraDepthTexture,就是从当前摄像机渲染结果里拷贝来的深度缓冲,而不是单独用Camera.main.Render()之类的接口额外渲染出来的数据。

需要留意的是,深度缓冲中的值并不等于“到相机的直线距离”。在透视投影里,GPU 为了在近处保留更高深度精度,会把深度压缩成和1/z相关的非线性值。近处的深度值精度高,远处精度低,这是符合视觉需求的。但我们在 Shader 里做距离衰减、景深、AO 这类效果时,需要的是真实距离,所以必须先做一次非线性到线性的转换。

2.2 非线性深度转线性深度:理解_ZBufferParams

Unity 在UnityCG.cginc里封装了几个常用函数,比如LinearEyeDepthLinear01Depth。我个人建议在项目里优先用这两个内置函数,不要自己造轮子,因为不同图形 API 的深度范围不一样,手写容易出平台兼容问题。

函数使用非常直观:

float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv); float eyeDepth = LinearEyeDepth(rawDepth); // 返回观察空间下的线性距离 float linear01 = Linear01Depth(rawDepth); // 返回 0~1 的线性深度

LinearEyeDepth的返回值适合做“和真实世界数值比较”的逻辑,比如 5 米外的物体渐隐;Linear01Depth更适合做平滑插值和阈值判断。它内部会依赖_ZBufferParams这个四分量参数,而_ZBufferParams的取值来自当前相机的近远裁剪面设置。理解到“透视投影矩阵会传入一个非线性的深度,内置函数负责还原成线性距离”这一步就够了,不必强行记公式,但也别完全当黑盒用。

2.3 Unity开启深度纹理的三条路径

我第一次用_CameraDepthTexture时,场景里一片黑,后来才发现是没有给摄像机开启深度纹理模式。常用开启方式如下:

Camera cam = GetComponent<Camera>(); cam.depthTextureMode |= DepthTextureMode.Depth;

在屏幕后处理脚本的OnEnable里执行这段代码即可。如果项目里已经存在多个后处理脚本,注意使用|=而不是直接赋值,避免覆盖其他脚本的设置。

还有一种方式是在 Shader 里通过CameraDepthTexture全局变量声明取样器,然后在后处理材质上自动生效。URP 管线中则需要在 Render Pipeline Asset 里勾选Depth Texture,或者通过DepthNormalsPass生成对应的纹理。无论是内置管线还是 URP,原理都是同一件事:“让摄像机额外输出那张原本只用于深度测试的底片”。

3. 屏幕空间法线纹理:一张不能直接看的“朝向地图”

3.1 法线纹理的编码与解码

法线向量是一个三维单位向量,每个分量范围是[-1, 1],但纹理通道通常只存储[0, 1]范围的数据。所以做法线纹理时,得先把向量做一次映射:

float3 normalWorld = normalize(i.normal); float3 encoded = normalWorld * 0.5 + 0.5;

解码时反过来:

float3 normalDecoded = normalColor.rgb * 2.0 - 1.0;

屏幕空间法线纹理本身看起来偏蓝紫色,是因为多数表面的法线都大致指向屏幕外的方向,蓝通道数值偏高。对于屏幕后处理来说,我们一般不直接用模型的切线空间法线贴图,而是用_CameraDepthNormalsTexture,它存储的是当前画面每个像素在观察空间下的法线和深度。

3.2 获取 DepthNormals 的两种方式:内置纹理 vs 从深度重建

第一种方式最省心:开启摄像机的DepthNormals模式。

cam.depthTextureMode |= DepthTextureMode.DepthNormals;

然后在 Shader 里声明:

sampler2D _CameraDepthNormalsTexture;

采样后用DecodeDepthNormal把深度和法线都拿出来:

float4 depthNormal = tex2D(_CameraDepthNormalsTexture, uv); float3 n = DecodeViewNormalStereo(depthNormal); float linear01Depth = DecodeFloatRG(depthNormal.zw);

第二种方式是“从深度重建法线”。原理是根据相邻像素的深度差,构造两个切向量,然后做叉积得到法线。好处是不额外消耗一张深度法线纹理的带宽,坏处是算法复杂,而且对深度噪声比较敏感。实际项目里如果不追求极致的带宽优化,直接用内置纹理更稳。

3.3 为什么“深度+法线”组合适合做边缘检测

单独的颜色边缘检测,比如经典的 Sobel,是对每个像素周围几个点的亮度差做判断。问题在于它无法区分“真实的几何边缘”和“纹理图案造成的亮度跳变”。

深度法线组合则不一样:深度值突然变化,说明这个位置大概率是物体轮廓;深度变化不大但法线方向突然变化,说明这个位置存在曲面转折,比如立方体的棱、圆柱的侧边、人物模型的肩部。这种判断方式对贴图内容完全免疫,不管墙上刷了多少花纹,都不可能被误判成几何边缘。

这也是第13章最精彩的地方:把视觉里“轮廓”这个问题,从颜色空间转换到了几何空间。

4. 让边缘检测从颜色中解放:深度法线边缘的实现思路

4.1 颜色 Sobel 的最大痛点

我在第12章写过一轮颜色 Sobel 边缘检测,效果在纯色场景里还不错,一旦放回真实场景就露馅了。地面贴图有石板缝,角色衣服上有花纹,灯光一照,高光变化带来的亮度差比真正的物体轮廓还要大,结果整个画面里全是密密麻麻的“线”。

这不是颜色 Sobel 算法写错了,而是“亮度差”这个信号本身就不稳定。光照方向一变、纹理一变,阈值就失效。所以我当时对边缘检测很灰心,直到第13章用深度和法线代替亮度,才意识到方向错了。边缘检测最该依赖的是几何信息,而不是颜色信息。

4.2 Roberts 算子如何同时比较法线和深度

《入门精要》第13章里用到的思路,本质上是 Roberts 算子:采样中心点周围的 4 个对角点,分别比较两个方向上的差异。下面是我根据书中思路整理的简化版本:

float CheckSame(float4 center, float4 sampleData) { float3 n1 = normalize(center.xyz); float3 n2 = normalize(sampleData.xyz); float normalDiff = 1.0 - saturate(dot(n1, n2)); float depthDiff = abs(center.a - sampleData.a); float depthThreshold = _DepthThreshold * max(center.a, sampleData.a); if (normalDiff > _NormalThreshold) return 1.0; if (depthDiff > depthThreshold) return 1.0; return 0.0; }

采样时,以屏幕坐标为中心取四个偏移:

float2 texel = _MainTex_TexelSize.xy * _EdgeSampleDistance; float edge = 0; edge += CheckSame(depthNormal, tex2D(_CameraDepthNormalsTexture, uv + texel * float2(1, 1))); edge += CheckSame(depthNormal, tex2D(_CameraDepthNormalsTexture, uv + texel * float2(-1, 1))); edge += CheckSame(depthNormal, tex2D(_CameraDepthNormalsTexture, uv + texel * float2(1, -1))); edge += CheckSame(depthNormal, tex2D(_CameraDepthNormalsTexture, uv + texel * float2(-1, -1)));

最终用edge在背景色和边缘色之间插值。需要注意,_CameraDepthNormalsTexture解码后的a通道是线性深度,所以深度比较要基于它做,而不是直接拿原始采样值比较。

4.3 阈值怎么调才能让描边“脏而不乱”

调参时最容易遇到两个极端:阈值太小,画面几乎所有地方都被当成边缘;阈值太大,物体轮廓断断连连。我自己的习惯是先关掉法线判断,只留深度阈值,观察几何硬边缘是否清晰;再关掉深度判断,只看法线阈值,找出曲面转折。两者基本稳定后,再同时开启,整体微调。

_DepthThreshold我建议设置在 0.01 到 0.1 之间,具体数值要结合场景尺度。因为深度值已经转成线性 01,小物体和大场景需要的灵敏度完全不同。_NormalThreshold通常控制在 0.01 到 0.2,值越大描边越少。

还有一个容易被忽略的参数是采样距离。采样距离太小,边缘会很细且容易有锯齿;采样距离太大,边缘会膨胀,甚至出现双层边。屏幕后处理里一般从 1 个像素开始试,最多不要超过 2 到 3 个像素。

5. 用深度纹理重建世界坐标:给数字孪生场景做遮挡高亮

5.1 一个 raycast 做不到的逐像素世界坐标

数字孪生、智慧园区这类项目里,经常要做“设备被楼体挡住时显示半透明轮廓”的效果。很多初学者第一反应是用射线检测:从相机朝物体发射一条 ray,看看中间有没有遮挡。这个方案只适合判断单个物体,要是墙上有窗户、围栏、管廊,射线检测的采样点不够,结果会很零碎。

正确姿势是在屏幕后处理阶段,逐像素重建世界坐标。因为每个像素都已经有了 uv 和深度,只要知道当前相机把世界坐标投影到屏幕的矩阵,就能把这一条路径反推回去。这个方法天然具备逐像素精度,不依赖碰撞体,也不会被透明物体干扰。

5.2 通过 VP 逆矩阵还原世界坐标的完整代码

核心思路是先把 uv 还原到裁剪空间,再用逆矩阵乘回去。C# 侧代码:

Camera cam = GetComponent<Camera>(); Matrix4x4 vp = cam.projectionMatrix * cam.worldToCameraMatrix; Shader.SetGlobalMatrix("_InverseVP", vp.inverse);

Shader 侧这样还原:

float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv); #if UNITY_UV_STARTS_AT_TOP clipPos.y = -clipPos.y; #endif clipPos.x = uv.x * 2.0 - 1.0; clipPos.y = uv.y * 2.0 - 1.0; clipPos.z = depth * 2.0 - 1.0; clipPos.w = 1.0; float4 worldPos = mul(_InverseVP, clipPos); worldPos /= worldPos.w;

这里最关键的是最后一步:除以w。因为逆矩阵变换后的结果是齐次坐标,必须除w才能得到真正的世界坐标。很多自学者第一次遇到这个步骤都会困惑,但只要联想到透视投影里所有点最后都要做一次透视除法,就能理解为什么这里要还原回去。

有了逐像素世界坐标后,你就可以做很多事情:用worldPos.y做扫描效果,用distance(worldPos, _WorldSpaceCameraPos)做距离渐隐,或者把世界坐标和某个目标点的包围盒做比较,输出遮挡掩码。

5.3 遮挡提示与距离渐隐的实用组合

我在一个数字孪生小 Demo 里这样组合:

  1. 先用深度+法线边缘检测提取被遮挡物体的轮廓;
  2. 再用深度纹理重建世界坐标,判断当前像素是否属于目标设备的包围盒范围;
  3. 对属于目标范围内的边缘像素混合一层高亮色;
  4. 对非边缘但属于设备范围内的像素,通过距离渐隐控制透明度。

这个组合做出来后,设备被楼体挡住时,可以看到半透明的轮廓浮在墙上。移动视角,轮廓跟随设备位置实时变化,体验比 switch 到 X-Ray 模式自然很多。这个效果用第13章的知识已经完全能实现,不需要额外的后处理插件。

6. 第13章自学踩坑记录:这些坑我每一行代码都试过

6.1 DepthTextureMode 没生效,先查 Camera 和 Frame Debugger

最常见的问题是:代码里设置了depthTextureMode,但 Shader 里采样_CameraDepthTexture仍然是黑色。第一反应应该打开 Frame Debugger,逐条看相机渲染事件里是否多了一个DepthNormalsPass。如果没多,多半是后处理脚本的OnEnable没执行,或者脚本附加的摄像机不是当前渲染摄像机。

另一个容易踩的坑是:使用多个后处理脚本时,其中一个脚本把depthTextureMode赋值成DepthTextureMode.Depth,另一个脚本在它之后又设置成DepthTextureMode.DepthNormals,覆盖掉了前面的设置。解决办法是统一用|=累加,或者让一个全局脚本管理depthTextureMode

6.2 深度值在 DX 和 GL 上不一样,别写死平台逻辑

同一个深度纹理,在 DX 系平台和 GL 系平台上的深度范围方向可能是反的。如果在 Shader 里自己比较深度,不要默认“深度值越小越近”或者“越大越近”,建议统一使用 Unity 内置的LinearEyeDepth/Linear01Depth

手写重建世界坐标时,也需要处理UNITY_REVERSED_ZUNITY_UV_STARTS_AT_TOP。这些宏看起来繁琐,但缺一个就可能出现只在 Windows 上正常、在 Android 上花屏的问题。建议把重建逻辑封装成一个函数,项目里统一调用,不要每个效果复制一份带坑的代码。

6.3 透明物体不写深度:法线纹理背景是空的

深度纹理来自深度缓冲,而深度缓冲通常只记录不透明物体。带透明度的物体,比如粒子、玻璃、半透明布料,默认要么不写深度,要么深度值已经被透明通道混合掉了。所以在做屏幕后处理时,不要指望深度法线纹理能正确描述透明物体的轮廓。

如果项目必须对透明物体做描边,就得单独处理:要么让透明物体在一个额外的 RenderObject 阶段写入深度,要么用CommandBuffer再渲染一次透明物体。第13章的示例默认处理不透明场景,我自己第一次做玻璃描边时在这里卡了很久,最后才发现不是 Shader 写错了,而是数据源本身没有透明物体信息。

6.4 别把相机深度纹理和阴影图混为一谈

很多人在做“阴影问题”排查时,会误以为_CameraDepthTexture就是阴影相关的深度图。实际上这两者完全不同:相机深度纹理描述的是“从当前摄像机看过去的深度”,阴影图描述的是“从光源方向看过去的深度”。阴影采样通常用_ShadowMapTextureScreenSpaceShadowMap,和屏幕后处理里的_CameraDepthTexture是两套资源。

如果发现开启depthTextureMode后阴影变暗或变亮,大概率不是深度纹理本身引起的,而是后处理 Shader 里擅自修改了_ProjectionParams_ZBufferParams之类的全局参数,或者开启的 Pass 数量影响了带宽性能。排查时先用 Frame Debugger 确认阴影 Pass 和深度法线 Pass 是否都在正常执行,再考虑算法问题。

7. 学完第13章之后,我给自己安排的下一步

7.1 把书里例子改成一个小型遮挡高亮 Demo

光学不练很难形成长期记忆。我在读完第13章后,没有直接进入第14章,而是先做了一个“角色被墙挡住时显示蓝色轮廓”的小 Demo。里面同时用到了深度纹理、法线纹理、世界坐标重建和边缘检测,等于把新知识全部串了一遍。

做 Demo 的过程比看书更能暴露问题:边缘检测为什么在特定光照下闪烁?重建的世界坐标为什么远处抖动?同一个效果在 PC 和手机上为什么效果不同?这些问题书里未必有答案,但只有实际遇到并解决,知识才真正长在自己身上。

7.2 从内置管线迁移到 URP 的 DepthNormals

现在很多新项目已经切换到 URP。URP 里获取深度纹理的方式略有不同,需要在UniversalRenderPipelineAsset中开启Depth Texture,或者在 Renderer Feature 里添加DepthNormalsPass。Shader 里采样节点更直观,但内置管线时期理解的那些底层原理完全复用。

迁移时最容易踩的是深度值和 UV 方向,在 URP 中的封装可能和内置管线不一致。我建议先画一个“深度转颜色”的调试 Shader,把深度纹理可视化,确认线性深度范围正常后,再叠加边缘检测和重建逻辑。

7.3 给同为技术美术的读者三条建议

第一条,遇到效果不对,先用 Frame Debugger 看 Pass 序列,不要盲猜 Shader 里的某个加减乘除。第二条,把阈值、采样距离、颜色这类参数全部暴露成 Material 属性,方便在场景里实时调。第三条,不要背代码,而是把原理画出来:画一条光线,画一个视锥,画一张深度缓冲的曲线,理解比记住重要得多。

我个人做完这个章节最大的体会是:技术美术很多所谓“进阶效果”,拆到底其实都是对几何信息的理解。颜色是表面的皮,深度和法线才是骨架。把第13章吃透,后面看任何后处理源码都会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询