Unity3D Shader渲染流水线:从Draw Call到逐片元操作

发布时间:2026/10/27 19:56:11
Unity3D Shader渲染流水线:从Draw Call到逐片元操作

做渲染这行时间长了会发现一个挺有意思的现象:很多人学 Shader 的路径是从抄代码开始的,改个颜色、换个贴图、加个 Rim 光,跑起来就行,但一旦画面出问题或者帧率掉下去,就完全不知道该往哪儿看。我自己也走过这段路,早期写的一个描边效果,在编辑器里跑得好好的,打包到真机上边缘全是锯齿,折腾了两天才反应过来是分辨率变了导致采样率不够——这事跟 Shader 的语法一点关系都没有,纯粹是对流水线后半段发生了什么没概念。

这篇内容聊的就是Unity3D Shader 管道渲染流水线这套东西。它不是某个 API 的用法手册,而是从 CPU 提交一次绘制请求开始,一路到像素写进帧缓冲结束,中间到底经过了哪些环节、每个环节在做什么判断、哪些参数控制着这些判断。搞懂这条链路之后,你会发现自己看 Shader 代码的眼光会变:那些ZWrite Off、Cull Back、Blend SrcAlpha OneMinusSrcAlpha不再是"前辈说要这么写"的咒语,而是有明确物理含义的开关。

适合谁看?如果你是刚接触 Shader、能看懂vert和frag函数但说不清它们之间发生了什么,这篇能帮你把中间那段空白补上;如果你已经写过一些效果,但经常被排序错乱、深度穿插、半透明发黑这类问题搞得很烦,后面几节的参数组合表和排查思路应该能直接拿去用。整篇按流水线的实际执行顺序展开,从应用阶段讲到逐片元操作,最后落到实操里最常见的那些坑。


1. 先把整条链路装进脑子:渲染流水线到底分几段

我习惯把渲染流水线当成一条工厂流水线来理解:原材料是场景里的模型顶点数据和材质参数,成品是屏幕上的一块颜色。中间有若干道工序,每道工序只负责一件事,做完就把半成品交给下一道。GPU 之所以快,很大程度是因为这条流水线可以并行——上一批顶点还在做坐标变换,下一批顶点已经在准备数据了,片元处理也是成批推进,不需要等前一道完全结束。

教科书上一般把它切成应用阶段、几何阶段、光栅化阶段三段,我更喜欢拆成四段,因为逐片元操作在实操中出问题的频率太高,值得单独拎出来说。这四段分别是:应用阶段(CPU 主导,准备数据、剔除、设置渲染状态、发绘制调用)、几何阶段(GPU 处理顶点,做坐标变换、组装图元、裁剪)、光栅化阶段(把三角形转成一个个屏幕上的候选像素点)、逐片元操作(对每个候选点做深度、模板、混合判断,最终决定颜色写不写、怎么写)。

1.1 四个阶段的职责边界与各自的瓶颈特征

应用阶段是唯一由 CPU 主导的阶段,也是最容易被忽视的一段。很多人写 Shader 时眼睛只盯着 GPU,但实际项目里卡顿的元凶有很大比例出在这里:材质切换太频繁、渲染状态反复变更、绘制调用数量爆炸、剔除没做或者做过头。这个阶段输出的核心产物是绘制调用(Draw Call),每次调用带着一批顶点数据、一个材质状态和一组渲染状态参数,交给 GPU 去执行。

几何阶段负责把一个一个孤立的顶点变成有形状的三角形,并且把它们从模型自己的坐标系搬到屏幕坐标系里去。这个阶段的输入输出都是顶点级别的数据,核心工作单元是顶点着色器。它的可编程部分主要就是顶点着色器,其余像曲面细分、几何着色器属于可选环节,移动端很多设备根本不支持或者支持得很勉强,我一般不推荐在跨平台项目里依赖它们。

光栅化阶段是硬件固定功能的部分,不可编程。它做的事情说起来简单:拿到屏幕空间里的三个顶点,判断这三个点围成的三角形覆盖了哪些像素中心,把这些像素点标记成待处理的片元。判断"覆盖"这件事靠的是扫描线和包围盒,现代 GPU 一般是算三角形的包围盒然后逐行扫描,效率比朴素的逐像素判断高得多。

逐片元操作是片元着色器之后的那道关卡,也是决定"最终谁显示在前面"的地方。模板测试、深度测试、混合、写入掩码,全在这一段。这段的规则看起来枯燥,但透明效果做不对、模型互相穿插、后处理叠上去变黑,十有八九是这里的参数没配对。

提示:这四个阶段里,只有顶点着色器和片元着色器是你能写代码干预的。其余部分是固定管线或者硬件行为,你能做的只是通过状态参数去影响它的判断逻辑。理解这一点很重要,它意味着遇到问题时先想"这是哪个阶段的事",而不是上来就改 Shader 代码。

1.2 为什么只盯着 Shader 代码会走进死胡同

我见过不少项目,性能出问题后的第一反应是"优化 Shader",把片元着色器里的运算砍了又砍,结果帧率纹丝不动。原因很简单:瓶颈可能根本不在片元着色器上。如果场景里有几千个绘制调用,CPU 光是把命令塞进命令缓冲区就忙不过来了,GPU 那边甚至在闲着等。

判断瓶颈在哪个阶段,有一套比较土但很好用的办法:把分辨率从 1080p 降到 480p 再跑一遍。如果帧率明显上升,说明瓶颈在像素相关的环节,也就是光栅化和逐片元操作,具体说是填充率不够、Overdraw 太重、片元着色器算得太贵;如果帧率基本没变化,那瓶颈多半在 CPU 侧的应用阶段,或者顶点数量太多导致的几何阶段压力。

这套判断方法的逻辑在于:降低分辨率会大幅减少需要处理的片元数量,但对绘制调用数量和顶点数量的影响很小。所以帧率对分辨率敏感,就说明是像素侧的锅;不敏感,就是顶点侧或者 CPU 侧的锅。我在实际项目里用这招定位过好几次问题,比盲目猜要靠谱得多。

2. 应用阶段:那些看起来跟 Shader 无关却决定成败的准备

应用阶段做的事情可以概括成一句话:把这一帧要画的东西整理好,用尽可能少的次数告诉 GPU。具体拆开是三步——剔除掉看不见的东西、设置好渲染状态、发出绘制调用。这三步里任何一步做得不讲究,后面的 GPU 阶段再优化也救不回来。

2.1 渲染状态到底缓存了什么,为什么切换它是昂贵的

渲染状态是一组告诉 GPU"接下来这批数据按什么规则处理"的参数集合,主要包括:使用的 Shader 程序、材质属性值(贴图、颜色、浮点参数)、混合模式、深度测试规则、剔除模式、写入掩码、模板参数等等。这些参数在 GPU 内部是存在寄存器或者状态缓存里的,切换一次就意味着要重新配置这批寄存器。

单次切换的开销其实不大,问题在于规模。一个中等复杂度的场景,如果每个物体都用不同材质、不同 Shader,那每帧的渲染状态切换次数可能上万。这时候哪怕每次只花几微秒,累加起来也是几十毫秒,帧率直接掉到三十以下。

所以优化应用阶段的核心思路就是减少状态切换次数。做法上,最直接的是按材质排序:把用同一个材质的物体排在一起渲染,这样状态只需要设置一次。Unity 内部其实会做一定程度的排序,但它的排序逻辑跟我们想要的不完全一致,尤其是在混合使用不透明和透明物体的时候。我的习惯是在自定义渲染管线里显式做一遍排序,把不透明物体按材质和 Shader 分组,透明物体单独按距离排序。

这里有个容易踩的坑:排序键的选择。只按材质 ID 排序,可能会导致深度跨度很大的物体被排在一起,前向渲染下反而增加了 Overdraw;只按距离排序,状态切换次数又会飙升。比较稳的做法是多级排序:第一级按渲染队列(不透明在前、透明在后),第二级按材质,第三级按距离。代价是要维护一套排序数据结构,收益是状态切换和 Overdraw 都能压下来。

2.2 Draw Call 的真实成本和合批的边界在哪里

关于绘制调用,网上流传最广的说法是"每次绘制调用大约 1 毫秒开销",这个数字在十年前可能有参考价值,现在早就过时了。现代引擎里一次绘制调用的 CPU 开销取决于很多因素:引擎自己的中间层有多厚、有没有做参数校验、提交命令的方式是即时模式还是命令缓冲。Unity 里相对实在的经验值是零点几毫秒的量级,但不同版本、不同平台差异很大。

真正有价值的不是记住某个数字,而是理解合批为什么能省开销。合批的本质是把多个物体的顶点数据合并到一个缓冲区里,用一次绘制调用提交。省下的开销主要在 CPU 侧:不用反复设置渲染状态、不用反复提交命令。代价是合并过程本身要消耗 CPU,而且合并后的物体必须共用同一个材质,位置信息被烘焙进顶点数据里,运行时就不能再单独移动了。

Unity 提供三种合批路径,各有各的适用边界,我整理了一张对照表:

合批方式触发条件优点硬性限制
静态合批勾选 Static 标记,构建时合并运行时零开销内存占用大,物体不能移动,顶点数上限较低
动态合批顶点数少、同材质、同缩放支持移动物体顶点属性受限,大模型直接失效,CPU 合并开销随数量上升
GPU Instancing同网格同材质,开启开关一次调用画多个实例需要硬件支持,材质属性无法逐实例差异化(除非用实例化属性)

这三种方式里,我实际项目中用得最多的是 GPU Instancing,因为它的自由度最高,性能收益也最明显,尤其是植被、碎石、子弹这类大量重复的小物件。但要注意一个细节:Instancing 的收益体现在绘制调用数量上,如果你的物体本来就只有几十个,收益非常有限,反而是静态合批更划算。

注意:动态合批有个很隐蔽的限制——它对顶点属性的数量很敏感。如果你的 Shader 用了额外的 UV 通道(比如光照贴图 UV),动态合批就可能直接失效,而且引擎不会给你任何提示。我就遇到过 Shader 加了一个TEXCOORD1之后合批悄悄消失的情况,查了半天才发现。

2.3 剔除策略省的是 CPU 还是 GPU,别搞反了

剔除这件事,很多人以为是为了省 GPU 的绘制开销,其实不完全对。视锥剔除在应用阶段完成,主要收益是减少了送入几何阶段的顶点数量,同时也就减少了绘制调用。所以它的收益是双份的:CPU 侧少了提交,GPU 侧少了顶点处理。

Unity 自带的视锥剔除是默认开启的,依靠渲染器的包围盒做判断。这里有个常见问题:包围盒不准。如果模型的包围盒是自动计算的,而模型本身有顶点动画或者顶点着色器里做了位移(比如旗帜飘动、水波起伏),那么包围盒就只能覆盖原始网格的范围,位移之后的顶点可能跑到包围盒外面去,结果是物体明明在视野里却被剔掉,出现闪烁或者整块消失。

解决思路是手动扩大包围盒。在渲染器上可以直接设置bounds,或者改网格的bounds属性。扩大多少要看位移的幅度,宁可多留一些余量,因为剔错的视觉代价远大于多画几个三角形的性能代价。

遮挡剔除的情况更复杂一些。Unity 的遮挡剔除需要烘焙遮挡数据,适合静态场景,对动态物体基本无能为力,而且在场景结构变化后会失效。我的做法是:静态大场景(比如城市街区、大型室内)值得烘一次遮挡数据;小型场景或者大量动态物体的场景就直接放弃,用 LOD 和距离剔除来替代,性价比更高。

3. 几何阶段:顶点着色器真正的职责范围

几何阶段是很多人学 Shader 的起点,也是误解最多的地方。最常见的误解是把顶点着色器当成"处理顶点的地方",然后往里塞各种逻辑,比如光照计算。这么写虽然能跑,但如果光照依赖的是每个像素的位置而不是每个顶点的位置,那结果就是对象越小、光照越糊。理解顶点着色器的职责边界,比记住它的语法重要得多。

3.1 顶点着色器里唯一必须完成的事

顶点着色器接收一个顶点,输出一个顶点。输入是模型空间下的位置、法线、切线、UV 等属性,输出的是裁剪空间下的位置,外加一些你希望插值到片元阶段的附加数据。整个过程里,唯一不可省略的操作就是坐标变换:把模型空间的顶点位置乘以 MVP 矩阵,得到裁剪空间位置。

为什么这件事不能省?因为流水线后面的裁剪和光栅化都依赖裁剪空间坐标。GPU 需要知道每个顶点在裁剪体(一个立方体)内部还是外部,在内部的继续往下走,在外部且落在同一侧的整个图元直接丢弃,跨越边界的图元则被裁剪成新的图元。没有裁剪空间坐标,这套判断就无从谈起。

除了位置,顶点着色器里通常会做的还有:法线变换(注意要用逆转置矩阵,否则非等比缩放下法线方向会歪)、切线变换(用于法线贴图)、顶点色和 UV 的原样传递、以及一些轻量的顶点动画(风吹草动、旗帜飘动)。至于光照、纹理采样这些,理论上也可以在顶点阶段做,但除非是特别低端的设备或者特别风格化的需求,一般不建议——顶点级光照的精度在今天的屏幕上实在太糙了。

3.2 五个坐标空间的变换顺序,以及为什么矩阵顺序不能反

从模型空间到屏幕空间,中间要经过五次变换,每一步用的矩阵不一样,顺序绝对不能乱:

变换步骤空间转换常用矩阵常见错误
模型变换模型空间 → 世界空间unity_ObjectToWorld忽略缩放导致法线方向错误
视图变换世界空间 → 观察空间unity_MatrixV 或 UNITY_MATRIX_V手动构造时搞错手性
投影变换观察空间 → 裁剪空间unity_MatrixP 或 UNITY_MATRIX_P透视投影下忘记处理 w 分量
透视除法裁剪空间 → NDC硬件自动完成在片元阶段手动除以 w 导致二次除法
视口变换NDC → 屏幕空间硬件自动完成与屏幕 UV 的 y 轴方向搞混

Unity 的UnityObjectToClipPos这个函数把前三步打包做完了,所以大多数时候你不需要关心中间细节。但如果要做一些特殊效果,比如把顶点投影到某个平面上做假阴影,就需要手动把变换拆开算,这时候矩阵顺序就变得非常关键。

顺序的逻辑其实很好记:每个变换都是在"上一个空间"里操作。模型变换是在世界空间里摆放物体,视图变换是在世界空间里挪动摄像机,投影变换是在观察空间里决定怎么"看"这个场景。所以是 M(模型)在最先,然后是 V(视图),再是 P(投影),左乘的顺序就是 P × V × M × 顶点。写反了的话,相当于先投影再摆放,结果完全不对。

3.3 裁剪、背面剔除与屏幕映射的三个实操细节

背面剔除是几何阶段里最省事也最容易被忽略的优化。默认情况下 Unity 会剔除背面朝向摄像机的三角形,判断依据是顶点顺序(顺时针还是逆时针)结合观察方向。这个开关由Cull指令控制,取值Back、Front、Off。绝大多数的实体模型用Back就够了,但有两类情况需要特别处理:一是双面材质(比如树叶、布料、纸片),必须设成Off;二是需要在物体内部观察的场景(比如房间内壁),可能需要设成Front。

设成Off的代价是三角形数量翻倍地参与后续处理,所以能不用就不用。树叶这类用Off的对象,更好的做法是准备两套法线朝向不同的网格,或者用几何着色器复制一遍三角形并翻转绕序,性能表现比直接关剔除要好。

裁剪的触发条件是新图元跨越了裁剪体边界。这里有个容易误解的地方:裁剪是在裁剪空间里做的,判断依据是-w <= x, y, z <= w这组不等式。新手常犯的错是在顶点着色器里把 w 分量改掉,比如为了做某些特效把 w 设成负数或者零,结果整个物体直接消失。w 分量在透视投影里携带的是观察空间下的深度信息,透视除法要靠它当分母,所以动它之前得想清楚后果。

屏幕映射是硬件自动完成的,把 NDC 里的[-1, 1]范围映射到屏幕像素坐标系。这一步不需要你写代码,但需要你理解它的结果,因为片元着色器拿到的屏幕坐标就是映射后的结果。Unity 里可以通过ComputeScreenPos或者 VPOS 语义拿到屏幕位置,用在屏幕空间效果上(比如深度雾、屏幕空间折射)。这里要注意不同图形 API 的 y 轴方向不一样,如果做跨平台项目,拿屏幕坐标做效果时最好用 Unity 提供的宏而不是硬编码。

4. 光栅化:三角形变成片元的这一跳

光栅化是整个流水线里最"硬件"的一段,完全不可编程,但你写的每一行片元着色器代码都是在这一段之后才执行的。理解它做什么,能帮你解释很多看起来莫名其妙的现象,比如为什么细长的三角形容易出现闪烁、为什么小物体上的贴图会糊成一片。

4.1 三角形遍历与重心坐标插值

光栅化的核心动作是判断哪些像素被三角形覆盖。具体的做法是取三角形的包围盒,然后逐个像素判断它的中心点是否在三角形内部。判断"在内部"用的是叉积或者重心坐标法:对于一个三角形 ABC 和点 P,如果 P 相对三条边的叉积符号都一致,那 P 就在三角形内。

这个判断只用了像素中心点,所以会出现"锯齿"——三角形的边缘是斜的,但像素是方的,只能近似。这也是为什么 MSAA 这类抗锯齿技术要在一个像素里放多个采样点,判断覆盖率而不是单点判断。

一旦确定某个像素被覆盖,接下来就是插值。顶点着色器输出的那些附加数据(UV、法线、顶点色、世界坐标)需要在这些片元上取值,用的是重心坐标做线性插值。重心坐标可以理解为"这个片元离三个顶点各有多近"的权重,权重和为 1。UV 就等于三个顶点 UV 按权重加权求和。

这套插值是线性的,所以如果两个顶点的 UV 相差很大,中间过渡区域就会出现拉伸或者扭曲。这也是为什么做大面积地板时要注意网格细分:UV 差异过大的大三角形,中间区域的插值结果可能和预期的贴图映射偏差很远。

4.2 透视校正插值与 W 分量

这里有个容易被忽略但影响巨大的细节:透视校正插值。在透视投影下,远处的物体在屏幕上看起来更小,如果简单地做线性插值,纹理在远处会出现明显的扭曲。硬件做的实际上是除以 w 之后的插值,再乘回去,这样得到的才是视觉上正确的效果。

这个机制带来一个实际影响:如果你在顶点着色器里手动修改了 w 分量,插值结果会跟着变。某些做"顶点位移"的特效会利用这一点——通过修改 w 来改变透视效果。但同样地,如果 w 被改成了接近零的值,插值结果会急剧放大,出现各种诡异的拉伸和闪烁。

我在做水面效果时踩过这个坑:为了让浪花看起来更有层次,我在顶点着色器里根据距离调整了顶点的高度,但忘记同步考虑裁剪空间的 w,结果远处的水面纹理被拉成了一条条长条。修正方式是把位移量限制在一个合理范围内,或者干脆把位移放在观察空间之后再做。

4.3 采样率、抗锯齿与性能的三角关系

光栅化的采样点数决定了边缘的平滑程度。一个像素只有一个采样点时,边缘就是硬的;有四个采样点时,边缘就会按覆盖率做混合,看起来更平滑。MSAA 就是干这个的,它增加的是覆盖判断的采样数,但片元着色器只执行一次(每个像素一次),所以它的成本主要来自显存带宽和光栅化本身,而不是着色计算。

这也是为什么 MSAA 在延迟渲染下用不了或者效果不好——延迟渲染的 G-Buffer 存的是每个像素一份数据,MSAA 需要每个采样点一份,显存开销直接翻几倍。所以延迟渲染一般用后处理抗锯齿(比如 FXAA、TAA),代价是会出现模糊或者拖影。

选择哪种抗锯齿,我的判断标准是这样的:如果项目是移动端、要求功耗低,直接用 FXAA,便宜且效果凑合;如果是 PC 端、画质优先、用了前向渲染,MSAA 是很划算的选择;如果用了延迟渲染或者需要处理大量细小几何体(比如铁丝网、植被),TAA 效果最好但要注意它在快速运动场景下的拖影问题,可能需要配合运动矢量做修正。

5. 逐片元操作:决定最终谁显示在前面的那道关卡

片元着色器算完颜色之后,这个颜色并不会直接写进帧缓冲。它先要过几道关卡:模板测试、深度测试、混合。这几道关卡的规则由状态参数控制,参数配错的后果就是透明物体发黑、物体互相穿透、后处理叠上去一片糊。

5.1 深度测试与 ZWrite、ZTest 的组合逻辑

深度测试的本质是解决"遮挡"问题:每个片元带着自己的深度值(离摄像机的距离),跟深度缓冲里已有的值比较,通过比较才写入。比较规则由ZTest控制,是否写入由ZWrite控制。这两个参数是独立的,组合起来有四种典型用法:

ZWriteZTest典型用途后果说明
OnLEqual不透明物体(默认)正常遮挡,后画的近处物体覆盖远处
OffLEqual半透明物体不写深度但参与比较,避免透明物体互相遮挡出错
OnAlways特殊效果(如描边、遮罩)强制写入,会在深度缓冲里留下痕迹,慎用
OffAlways全屏后处理、UI忽略深度,最后画的盖住前面所有

半透明物体为什么必须ZWrite Off?因为它们需要和后面的物体混合,如果写了深度,后面绘制的透明物体就会被深度测试拒绝,导致有些透明物体看不到。但ZWrite Off带来一个新问题:透明物体之间的前后关系不再由深度决定,而是由绘制顺序决定。这就是为什么透明物体必须按距离从远到近排序。

这个排序在 Unity 里由渲染队列和引擎的排序逻辑共同完成,但引擎只能按物体的包围盒中心距离排,对于一个细长的物体(比如一根管子横跨整个场景),排序结果可能就是错的。这种时候只能手动拆分成多个子网格,或者接受一定程度的错误。

5.2 渲染队列的数值含义与透明排序的固有缺陷

Unity 的渲染队列是一个整数,数值越小越先渲染。内置的几个关键值:

  • Background= 1000,最早渲染,一般用于天空盒
  • Geometry= 2000,不透明物体的默认队列
  • AlphaTest= 2450,需要做 alpha 裁剪的物体
  • Transparent= 3000,半透明物体
  • Overlay= 4000,最后渲染,用于 UI、镜头光晕

数值上的间隔留出了自定义队列的空间,比如可以在 2000 到 2450 之间插入自定义的不透明队列。这个设计挺实用,但要注意队列只影响排序的优先级,不影响深度测试。也就是说,把两个不透明物体放在不同队列里,渲染顺序变了,但最终谁在前面还是由深度决定,视觉结果不变,只是性能可能有差异。

透明排序的固有缺陷在于它只能做到"物体级"排序,做不到"三角形级"。一个凹形或者自交的透明物体,无论怎么排都会有问题,因为它的不同部分相对于摄像机的前后关系是变化的。彻底解决只能靠逐三角形排序(OIT,顺序无关透明),代价是显存和带宽开销大,移动端基本不考虑。实际项目里的折中方案是:尽量让透明物体简单、凸形、不要太靠近摄像机;实在需要复杂透明效果(比如烟雾、玻璃碎片),用粒子系统或者预乘 alpha 来减轻视觉错误。

5.3 混合方程的拆解与常见配方的由来

混合是把新片元的颜色和帧缓冲里已有的颜色按某种规则合成。Unity 的Blend指令接收两个参数,分别是源因子和目标因子,最终的公式是:

最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子

常用的几种配方:

// 传统半透明:源颜色按 alpha 加权,目标颜色按 (1-alpha) 加权 Blend SrcAlpha OneMinusSrcAlpha // 预乘 alpha:颜色已经乘过 alpha,直接相加 Blend One OneMinusSrcAlpha // 叠加发光:源颜色直接加上去 Blend One One // 相乘(用于阴影、暗角) Blend DstColor Zero

为什么半透明是SrcAlpha OneMinusSrcAlpha?因为当 alpha 是 0 时,源颜色完全不贡献,目标颜色原样保留;当 alpha 是 1 时,源颜色完全覆盖,目标颜色不贡献。中间的 alpha 值做线性过渡,视觉上就是"半透明"的感觉。

预乘 alpha 的配方One OneMinusSrcAlpha看起来更简单,但要求美术在导出贴图时就把颜色乘上 alpha。它的优势在于能正确处理纹理过滤时的边缘问题——普通 alpha 混合在纹理放大时,透明边缘的颜色会混入背景色,出现黑边;预乘 alpha 因为颜色已经乘过 alpha,边缘是渐变的,不会出现这个问题。粒子特效特别容易出现黑边,用预乘 alpha 是个很有效的解决手段。

注意:混合会破坏 Early-Z 优化。GPU 的 Early-Z 是在片元着色器执行之前就做深度测试,能提前剔掉被遮挡的片元,省下着色计算。但如果 Shader 里开了混合、用了discard、或者写了深度,Early-Z 就会失效,退化成片元着色器执行完之后再做深度测试。这也是为什么 alpha test(用clip或discard)的开销比想象中大,它虽然省了混合,但把 Early-Z 干掉了。

6. Shader 代码的组织方式与变体管理

写到这里,流水线的各个阶段都过了一遍,接下来聊聊代码层面怎么把这些东西组织起来。Unity 的 ShaderLab 语法看起来简单,但 SubShader、Pass、Tags 这几层的层级关系和作用域规则挺容易搞混。

6.1 SubShader、Pass、Tags 的作用域

一个 Shader 文件里可以有多个 SubShader,引擎会从上往下挑第一个当前硬件能跑的。这个机制是用来做兼容性降级的:高端设备跑复杂版本,低端设备跑简化版本。实际项目里现在用得比较少,因为主流设备的差异没以前那么大了,但做跨平台项目时还是有价值。

每个 SubShader 里可以有多个 Pass。Pass 是最小的渲染单元,一个 Pass 对应一次完整的绘制流程。前向渲染下一个物体可能要走多个 Pass(每个光源一个),延迟渲染下 G-Buffer 一个 Pass、光照一个 Pass、最终合成一个 Pass。

Shader "Custom/PipelineDemo" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) _Cutoff ("Alpha Cutoff", Range(0,1)) = 0.5 } SubShader { Tags { "Queue" = "AlphaTest" "RenderType" = "TransparentCutout" } Pass { Tags { "LightMode" = "ForwardBase" } Cull Back ZWrite On ZTest LEqual Blend Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Cutoff; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 c = tex2D(_MainTex, i.uv) * _Color; clip(c.a - _Cutoff); return c; } ENDCG } } Fallback "Diffuse" }

这段代码里几个要点值得说明。Tags里的Queue决定渲染队列,RenderType主要给替换渲染、后期处理做识别用。Pass 里的LightMode标签决定这个 Pass 在什么光照阶段执行,前向渲染的基础 Pass 用ForwardBase。Fallback是兜底方案,如果前面的 SubShader 都跑不了,就用这个指定的 Shader。

TRANSFORM_TEX这个宏做的事情是把 UV 乘以贴图的缩放加上偏移,对应材质面板上的 Tiling 和 Offset 设置。很多人忘了用它,结果美术调了 Tiling 却没效果。

clip(c.a - _Cutoff)就是 alpha 裁剪,低于阈值的片元直接丢弃。用它的代价前面说过了,会破坏 Early-Z,但做栅栏、树叶这类效果时它是必需的。

6.2 关键字与变体爆炸的控制

变体是 Shader 里一个让人又爱又恨的机制。multi_compile和shader_feature会根据关键字编出多份代码,运行时按需切换。好处是能做功能开关,坏处是变体数量会指数级增长。

一个用了 5 个multi_compile布尔关键字的 Shader,理论变体数是 2 的 5 次方,也就是 32 个。每个变体都要编译、都要占内存、都要在包里占体积。项目里如果有几十个这样的 Shader,包体和内存就会非常难看。

我的做法有几条原则:第一,只把真正需要在运行时切换的做成 multi_compile,编辑器里配置一次就固定的功能用shader_feature,后者在构建时会把没被任何材质引用的变体剥掉。第二,能用分支解决的就不要用关键字,虽然 GPU 分支有开销,但比起几十个变体的开销,很多时候还是划算的。第三,定期用引擎的变体收集工具检查一遍,看看哪些变体从来没被用到,直接剥掉。

还有个隐蔽的坑:全局关键字。用Shader.EnableKeyword设置的关键字是全局的,会影响所有材质。如果多个系统各自设置全局关键字,可能出现互相干扰的情况,表现为"某个效果突然失效"。排查这类问题的诀窍是打印当前所有启用的关键字看看,往往能发现意料之外的开关。

6.3 参数优化:该省的地方和不该省的地方

Shade 里常见的性能坑,很多不在算法本身,而在数据精度和指令数量上。

精度方面,fixed、half、float在移动端是真的有区别,不是写写而已。颜色、UV 用fixed通常够用,位置和法线用half,世界坐标和高精度计算才需要float。把该用half的地方都标成float,会白白增加寄存器压力,严重的会导致 Occupancy 下降,性能损失可能有 20% 以上。

指令方面,片元着色器里的pow、sin、normalize都是相对昂贵的操作,能用乘加替代的就别用。比如球面高光里的pow(NdotH, _Gloss),如果 gloss 是固定的几个值,可以考虑用查找表或者多项式近似。但这属于比较极限的优化,项目处在开发中期的时候不建议过早做,先把功能跑通再优化。

还有一个常被忽略的点是纹理采样数量。每个采样都要占用纹理单元,移动端纹理单元数量有限,采样太多会严重拖慢。如果能用一张图打包多个通道(R 存粗糙度、G 存金属度、B 存 AO、A 存高度),就比用四张独立的图要好得多。这也是主流的"通道打包"做法。

提示:通道打包时要注意颜色空间转换的问题。如果贴图设置为 sRGB,采样后引擎会自动做 gamma 到线性的转换,但通道打包的非颜色数据(比如粗糙度、金属度)不应该做这个转换。把这类贴图设成 Linear 空间,或者把数据塞进 alpha 通道(alpha 通道不做 sRGB 转换)是个常见的规避办法。

7. 线上排查实录:画面异常与性能问题的定位表

理论讲完,落到最实用的部分。下面这些问题是渲染这条路上绕不开的,我把它们整理成了速查表,配合前面的流水线阶段来理解,定位起来会快很多。

7.1 画面问题速查:症状、可能原因与验证方法

症状最可能的阶段排查方向
半透明物体边缘发黑逐片元操作检查混合模式,考虑改用预乘 alpha
透明物体互相穿插逐片元操作 + 应用阶段检查排序,ZWrite 是否为 Off
物体表面出现深度闪烁逐片元操作深度冲突,检查 ZTest 和物体间距
边缘有明显的阶梯状锯齿光栅化检查抗锯齿设置,采样率是否足够
法线贴图方向错误几何阶段检查切线空间基向量,法线变换矩阵
模型在视野内消失应用阶段包围盒是否覆盖了顶点动画的范围
贴图在远处糊成一团光栅化mipmap 设置,各向异性过滤
描边效果宽度不一致几何阶段顶点沿法线外扩时受透视影响
后处理叠加后整体发灰逐片元操作混合模式错误,或者 sRGB 与线性空间不匹配

拿"边缘发黑"这条举例,原因通常是纹理过滤时透明边缘像素的颜色是黑色(因为贴图里透明区域的 RGB 是黑的),混合时这部分黑色就被混进去了。验证方法是把贴图放到图像编辑器里看透明区域的 RGB 值,如果是 0,那基本就是这个原因。解法可以是预乘 alpha,也可以是在贴图导出时把透明区域的 RGB 填成边缘色(也就是所谓的"颜色扩散")。

"深度闪烁"这条,本质是两个面片到摄像机的距离太接近,深度缓冲的精度不够区分。深度的精度分布不是线性的,近处精度高、远处精度低,所以远处的物体更容易闪。解法有几种:拉开物体间距、缩小摄像机的远裁剪面、或者用反转深度缓冲(平台支持的话)。我在实际项目里最常用的是缩小远裁剪面,代价是远景要另想办法。

7.2 性能问题排查:从现象反推瓶颈所在

性能排查我一般按这个顺序走:

第一步,确认瓶颈在 CPU 还是 GPU。用引擎自带的分析工具看帧时间分布,CPU 时间长就是应用阶段的问题,GPU 时间长就是几何、光栅化、逐片元的问题。

第二步,如果是 GPU 侧,降低分辨率重测。帧率随分辨率明显变化,说明是像素侧问题,重点看片元着色器复杂度、Overdraw、后处理链;帧率几乎不变,说明是几何侧问题,重点看顶点数量、绘制调用数。

第三步,针对性验证。怀疑 Overdraw 就关掉一部分透明效果再看帧率;怀疑片元着色器太贵就临时把frag改成直接返回固定颜色;怀疑顶点太多就关掉一部分模型。这种"关掉再看"的方法虽然土,但定位效率很高。

第四步,确认是不是状态切换的问题。统计一帧内的材质切换次数和绘制调用次数,如果这两个数字很大(比如几千),那优化方向就是合批和排序,而不是改 Shader。

几个常见的数据参考(不同平台差异大,仅供参考):绘制调用控制在几百以内比较安全,超过一两千就要考虑合批;Overdraw 在不透明区域控制在 1 到 2 倍,透明区域控制在 3 倍以内比较理想;单个片元着色器的指令数在移动端控制在 50 条以内比较稳妥。

7.3 几个踩过的坑,写下来给你省点时间

第一个坑是关于Cull Off的。做树叶效果时图省事关了背面剔除,结果在低端机上帧率掉了三成。后来改成用两套网格(正反两面的绕序不同)分别渲染,虽然绘制调用多了一次,但总开销反而降下来了。这件事让我意识到,绘制调用数量的增加不一定比填充率浪费更糟,要看具体场景。

第二个坑是关于discard的。做镂空效果时用它裁剪像素,编辑器里跑得好好的,打包后在移动端出现了严重的性能问题。查下来是discard破坏了 Early-Z,导致被遮挡的片元也要完整执行片元着色器。后来改用alpha to coverage(结合 MSAA),性能恢复了正常。这个方案不适用于所有平台,用之前得测一下目标设备。

第三个坑是关于渲染队列的。把两个半透明物体放在同一个队列里,靠距离排序,结果其中一个是长条形的管道横跨场景,不管怎么排都有一端是错的。最后的解法是把管道拆成若干段,每段独立参与排序,视觉上基本看不出拼接痕迹。

第四个坑是关于变体的。项目后期发现包体暴涨,查下来是一个 Shader 用了十几个关键字,变体数量上千。清理掉一半用不上的关键字,再开启变体剥离,包体直接小了十几兆。这件事之后我养成了习惯,每加一个关键字都问自己一句:这个真的需要在运行时切换吗?

第五个坑,也是最难查的一个:某个特效在大部分设备上正常,在某类设备上会出现边缘一圈奇怪的亮边。查了很久才发现是纹理采样的精度问题——那类设备的纹理过滤精度较低,在贴图边界采样时采到了相邻的像素。解法是在贴图边缘留一圈透明边距,或者在 Shader 里对 UV 做 clamp。这类问题没有通用解法,只能靠多设备覆盖测试来发现。

渲染这条链路,从应用阶段到逐片元操作,每一段都有自己的规则和陷阱。我现在的习惯是遇到问题先定位阶段,再去看具体参数,比上来就翻代码效率高得多。前面那些对照表和参数组合,你可以直接存下来当速查用,但真正让它们变成你自己的东西,还是得在项目里踩几次坑。