做渲染的老哥们应该都有印象,一聊到植被、铁丝网、头发、甚至飘落的纸屑,基本上就是在和“抠图”作斗争。这个抠图操作在渲染流水线里有个专门的名字:透明度测试(Alpha Test),在Unity URP里更常见的叫法是Alpha Clipping / Cutout。它发生在流水线的逐片元阶段,简单说就是光栅化之后、片元着色器输出颜色之前,根据片元的Alpha值决定要不要丢掉这个片元。这篇东西我就用URP为例,从原理讲到Shader实现,再把阴影、双面、排序、性能这些坑一个个填上。不管是刚学Shader的萌新,还是被植被/铁丝网状效果折磨过的老手,这篇文章应该都能给你一点参考。
1. 渲染流水线里,透明度测试到底站在哪一环
1.1 片元是怎么产生的,逐片元阶段又在忙什么
理解透明度测试之前,最好先把“片元”这个概念放对位置。渲染流水线大致可以拆成三段:应用程序阶段(CPU提交网格、材质、绘制指令)、几何阶段(顶点着色器做坐标变换)、光栅化与逐片元阶段(GPU把三角形变成覆盖屏幕的像素候选,再逐个决定颜色)。光栅化输出的一堆像素候选,就是片元,它不只是“屏幕上的一个格子”,还携带了从三角形顶点插值出来的UV坐标、法线、世界坐标、深度、顶点色等一堆数据。
逐片元阶段要做的事情很多:深度测试、模板测试、透明度测试、透明度混合、颜色写入都在这一环。关键点是,透明度测试本质上不是独立的固定功能硬件单元,它更常以一条指令的形式出现在片元着色器代码里。在D3D系和OpenGL系里,HLSL的clip()、GLSL的discard,本质上都是“这个片元我不要了,直接丢弃,不算进后续任何颜色操作”。Unity的URP把Shader编译成HLSL,再通过平台转换到不同图形API上,所以你在URP里写Shader时用到的clip(),到了移动端OpenGL ES上会被翻译成对应的字面量指令。
这里有个容易被忽略的点:片元着色器输出的是“这个片元应该显示的颜色”,但透明度测试发生在“输出颜色之前、决定是否保留片元”这个位置。换句话说,透明度测试是在片元着色器内部手动提前返回或丢弃片元,它和深度测试不是同一个时间点。深度测试通常在片元着色器之后、写入颜色缓冲之前执行,而你一旦在片元着色器里discard了,这个片元连深度测试的机会都没有。
用通俗的话讲,光栅化像快递分拣员把包裹送到每个收件人家门口,逐片元阶段是收件人拆包裹。透明度测试就是收件人看了一眼包裹上的标签,发现不是自己的,直接拒收。拒收的包裹不会出现在最终签收记录里,也不会占用后续仓储空间。
1.2 透明度测试 vs 透明度混合:两个世界
很多刚接触的人会把透明度测试和透明度混合混在一起,其实它们是完全不同的两个路径。透明度测试的结果只有两个状态:要么完全保留,要么整个丢弃。比如clip(tex.a - _Cutoff),当纹理Alpha小于0.5时直接扔掉,片元不存在了;大于等于0.5时则完全不透明地渲染。没有中间态,不会出现50%半透明的像素。
透明度混合则完全相反。它保留片元,把当前片元的颜色和背景颜色按照某个比例混合,比如常见的一行Blend SrcAlpha OneMinusSrcAlpha,实际上是在用Alpha权重混合前后颜色。最终结果就是你能透过半透明的玻璃看到后面的物体。透明度混合需要关闭ZWrite,因为它要让后面的半透明物体按距离排序,先进栈后出栈,否则会出现半透明物体之间的遮挡错误。
这个差异可以类比成剪纸和叠玻璃:透明度测试是剪纸,剪刀一剪,多余的纸直接掉地上;透明度混合是叠玻璃片,每片玻璃都有颜色深度,叠加起来产生透视效果。前者适合树叶、铁丝网、栅栏、头发片;后者适合车窗、水面、烟雾。
从配置上看,两者在Unity里最直观的区别是渲染队列和ZWrite。透明度测试通常走Queue=AlphaTest或者Queue=Geometry,ZWrite开启,Blend关闭;透明度混合通常走Queue=Transparent,ZWrite关闭,Blend开启。URP的Lit材质里也体现了这个区分:Surface Type选Opaque再勾选Alpha Clipping,和Surface Type选Transparent,二者背后是两条完全不同的渲染路径。
2. 一个最简URP Unlit透明测试Shader
2.1 代码与逐行拆解
先来一个最简单的Unlit透明测试Shader,不用内置Lit那么复杂,方便看核心逻辑。下面这段可以直接放到URP项目的Shader文件里:
Shader "Custom/URPUnlitAlphaTest" { Properties { _MainTex ("Texture", 2D) = "white" {} _Cutoff ("Alpha Cutoff", Range(0,1)) = 0.5 } SubShader { Tags { "Queue" = "AlphaTest" "RenderType" = "TransparentCutout" "RenderPipeline" = "UniversalPipeline" } Pass { Name "ForwardLit" Tags { "LightMode" = "UniversalForward" } ZWrite On Cull Back Blend Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float _Cutoff; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; VertexPositionInputs positionInputs = GetVertexPositionInputs(input.positionOS.xyz); output.positionHCS = positionInputs.positionCS; output.uv = input.uv; return output; } half4 frag(Varyings input) : SV_Target { half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); clip(texColor.a - _Cutoff); return texColor; } ENDHLSL } } }这段代码里有几个关键点,逐个说。首先在SubShader标签里,RenderPipeline = "UniversalPipeline"必须带上,这样URP才会把这条SubShader视为自己的,不会回退到内置管线。Queue=AlphaTest和RenderType=TransparentCutout帮助Unity在渲染顺序和着色器替换方面正确处理它,后者对面片高亮、阴影接收等内置功能很重要。
Pass里Tags { "LightMode" = "UniversalForward" }是URP前向渲染的主Pass标识。没有这个Tag,URP不认你这个Pass是主光照Pass,甚至可能不渲染你。然后是状态设置,ZWrite On表示这个Shader要写深度,Blend Off表示不做颜色混合。这样做会让镂空物体在深度缓冲里留下痕迹,后面的物体看不见它。栅栏后面如果有个箱子,箱子会被栅栏正确地挡住一部分,而不是隔着栅栏透视。
片元着色器里的核心只有一行:clip(texColor.a - _Cutoff)。这行指令会检查传入参数是否小于0,如果小于0就把当前片元丢弃。也就是说当纹理Alpha小于_Cutoff时,这个片元被扔掉了;大于等于时保留。这里也顺便解释了为什么_Cutoff通常取0.5:贴图Alpha通道里完全是白的区域才保留,灰的和黑的区域都被抠掉,能最大程度保持二值化效果。
2.2 它真的发生在逐片元阶段吗
很多教程只是把代码贴出来就完事,但我觉得值得深挖一下:为什么说这段逻辑是在逐片元阶段跑的?首先frag函数本身的语义就是片元着色器入口,它会被GPU在每个片元上执行。你可别把片元等同于像素,在链上可能还开得多像素抗锯齿时,一个像素大小可能会对应多个片元。关键点是frag执行时,顶点数据已经经过光栅化插值,我们拿到的input.uv已经是变换到屏幕空间后被插值出来的UV。
这时候可以做一个实验来验证它在流水线里的位置。找一张棋盘格Alpha贴图,一半Alpha为0,一半Alpha为1,然后对着一个平面渲染。在URP的Frame Debugger里打开DrawCall查看,可以看到顶点阶段并没有因为Alpha而为0的网格减面,三角形数量依然满的。在像素历史(RenderDoc)里,你可以看到那个被丢弃像素的片元Picking结果:它的Color输出根本没有被写入。这能说明一件事:透明度测试不是顶点级别剔除,而是光栅化之后片元级别的取舍。因此一个巨大平面在远处时,像素占比小,算法执行次数也少;但在近处或者高分辨率屏幕上,每个镂空像素都要执行一遍指令,这也是后面要说性能问题的伏笔。
2.3 状态配置:为什么是ZWrite On和Blend Off
上一段代码我明确写了ZWrite On、Blend Off,可能有人会问:这不都是Unity默认状态吗,写出来有必要吗?有必要。因为URP的Shader变体很多,状态设置会被合并,如果依赖默认值,一旦同时用了其他Pass或有内建混合逻辑,状态很容易被覆盖。而且写出来有个好处:阅读代码的人能一眼看出你的意图。
ZWrite On是透明度测试和透明度混合的重要分水岭。因为测试后的结果只有“完全保留”和“完全丢弃”两种,不会出现半透明像素,所以它可以正确写入深度,不需要像半透明物体那样在Transparent队列里搞排序。这也是为什么镂空植被可以处在Opaque阶段,和普通不透明物体共享渲染批次。
Blend Off同样重要。如果你不小心在透明测试Shader里开了Blend SrcAlpha OneMinusSrcAlpha,那么边界处半透明像素会和背景混合,生成白边或灰边。尤其纹理Alpha边缘如果是渐变半透,这种混合会更明显。遇到边缘发白的问题,先检查这个地方。
3. 在URP里做一套可用的Cutout材质
3.1 直接使用URP Lit的Alpha Clipping
如果你不想自己写Shader,URP内置Lit完全够用。操作非常简单:创建一个Lit材质,把贴图的Alpha通道填好,然后在Surface Options里把Surface Type保持在Opaque,勾选Alpha Clipping,下面的Threshold就是透明度阈值。这个Threshold默认0.5,当你贴图Alpha低于这个值时像素被丢弃。还有一个Alpha Clip Threshold是后处理/DepthPriming相关,这里不展开。
切换到Universal Render Pipeline Asset,看看有没有开启Depth Priming,这是另一个话题。在你只想要一个标准镂空材质时,这个方案不用写代码,适合快速验证效果。Lit的Alpha Clipping背后其实就是在片元着色器里调用clip(),而且它把相关代码用_ALPHATEST_ON关键字包起来,只在你勾选Alpha Clipping时才编译进Shader变体,从而减少运行时变体数量。
这里有个小提醒:Lit材质里如果同时想用Surface Type为Transparent,并且也勾选Alpha Clipping,也不是不行,但风险很大。Transparent队列会关闭ZWrite,物体之间可能需要Sorting Order排序,树叶在风里摆动时会前后穿插,最终排序一团糟。对于实体镂空物体,我的建议是永远用Opaque + Alpha Clipping,而不是Transparent + Alpha Clipping。
3.2 为什么Opaque + Alpha Clipping比Transparent更稳
原因不只是排序。Opaque + Alpha Clipping的绘制顺序在Unity的Geometry或AlphaTest队列,ZWrite是开的,深度缓冲会正确地写进去。后续的不透明物体在深度测试时会发现这里已经有了一个“实心”深度,所以不会被看到。而Transparent队列里如果你开了Alpha Clipping,但因为ZWrite Off,镂空物体自身之间没有深度联系,两个交叉的树叶平面会出现极其难看的排序闪动。当你移动镜头时,明明应该在前面挡住的叶片跑到了后面,观感非常糟糕。
性能上也差很多。不透明队列可以利用Unity的静态合批、SRP Batcher以及硬件Early-Z优化,而Transparent队列往往需要单独排序,批处理也可能被破坏。如果只是叶子、栅栏这类东西,完全没必要把它归到Transparent。遇到有人跟我说他的镂空树叶用了Transparent队列然后渲染顺序乱,我第一反应永远是让他切回Opaque + Alpha Clipping。
3.3 Shader关键字与SRP Batcher兼容性
自定义透明测试Shader时,如果要兼容URP的SRP Batcher,最容易被忽略的是CBUFFER。上面的代码里写了CBUFFER_START(UnityPerMaterial)、float _Cutoff、CBUFFER_END,这个是SRP Batcher能够合批的前提之一。浮点材质属性如果不放CBUFFER里,SRP Batcher会认为材质变化时无法安全地复用绑定缓冲,从而退回到普通合批,进而增加SetPassCall。当你场景里全是植物时,这个差别会非常明显。
很多时候我们还希望Shader支持关键字开关,这样就可以像Lit那样只在你勾选Alpha Clipping时才编译clip逻辑。典型写法是在Shader里加一行#pragma multi_compile_local _ _ALPHATEST_ON,然后在C#脚本或者材质面板上控制这个关键字。Unity的material.EnableKeyword("_ALPHATEST_ON")会切换变体。在HLSL里再包一层:
#ifdef _ALPHATEST_ON clip(texColor.a - _Cutoff); #endif当_ALPHATEST_ON关闭时,这一行根本不会编译进去,GPU也不需要执行clip指令。这对性能有好处,因为很多现代GPU遇到discard会禁用Early-Z,下面专门讲。
这里还要注意multi_compile_local和multi_compile的区别。如果你不需要和其他Shader共享变体,用local更好,因为它生成的变体数量更少,SRP Batcher也能正确处理。URP内置Lit对Alpha Clipping就是用了_ALPHATEST_ON这个全局关键字,但SRP Batcher下每个材质属性仍然放在UnityPerMaterial里,互不污染。
4. 阴影、双面、边缘……这些坑一次说清
4.1 ShadowCaster Pass:阴影不镂空的元凶
很多人在URP里写完透明测试Shader,主渲染没问题,树叶该凹的地方凹了,但往地上一看,影子是一个实心的正方形或者一片完整的叶子轮廓,没有镂空。原因很简单:主Pass里用clip抠掉了片元,但生成阴影的ShadowCaster Pass没有执行同样的clip逻辑。阴影贴图渲染时走的是另一个Pass,它不管主Pass输出什么颜色,只关心深度。默认情况下,URP没有为你的自定义Shader自动生成ShadowCaster Pass,所以Unity要么回退到内置的阴影Pass,要么根本不渲染阴影。
解决办法有两种。第一种,直接用URP内置Lit然后勾选Alpha Clipping,Lit的ShadowCaster Pass里会处理clip,阴影镂空是对的。第二种,自定义Shader里自己写一个ShadowCaster Pass,核心是在frag里同样采样Alpha并clip。我给你一个清爽的ShadowCaster Pass写法:
Pass { Name "ShadowCaster" Tags { "LightMode" = "ShadowCaster" } ZWrite On ZTest LEqual Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float _Cutoff; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; float3 posWS = TransformObjectToWorld(input.positionOS.xyz); output.positionHCS = TransformWorldToShadowCoord(posWS); output.uv = input.uv; return output; } half4 frag(Varyings input) : SV_Target { half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); clip(texColor.a - _Cutoff); return 0; } ENDHLSL }注意LightMode = "ShadowCaster"这个Tag是URP识别阴影Pass的标志。Punctual Light Shadow多编译关键字是为了支持透视聚光灯的阴影,如果你不需要可以去掉。有了这个Pass,植被阴影才会跟着Alpha一起镂空。否则你辛辛苦苦剪了树叶,结果影子是一大块黑,穿帮感极强。
4.2 Cull Off与双面法线修正
树叶、草、铁丝网这类物体往往是单面片,但你需要两面都能看到。默认的Cull Back会剔除背面,导致转过去时叶片消失。改法是Cull Off,双面都渲染。但这会带来一个附带问题:背面的法线方向仍然是原始法线,光照计算的时候背面会被当成朝里,导致出现暗面或者反而更亮的不自然效果。比如树叶正面被太阳照着是绿色,转过去背面却是黑的。
对于简单的不受光Unlit效果,这个问题不明显,但如果你的材质用了漫反射光照,就需要在片元阶段根据朝向翻转法线。一个常见做法是用屏幕空间导数判断facing。Unity内置函数Varyings里可以加一个float3 normalWS,在frag里通过cross(ddx(posWS), ddy(posWS))求面法线,再和normalWS点乘,判断正负:
float3 geometricNormal = normalize(cross(ddx(input.positionWS), ddy(input.positionWS))); float facing = dot(geometricNormal, input.normalWS) < 0 ? -1 : 1; input.normalWS *= facing;这样在片元着色器里根据面朝向修正法线,让背面也有正确的漫反射。当然,这会让Shader复杂一些,而且ddx/ddy在移动端有额外开销。如果你的植被基本都是卡通风格,不加光照,直接Cull Off也行。但如果要做相对真实的效果,背面法线修正还是值得的。
需要提醒的是,ShadowCaster Pass里如果也想双面产生阴影,Cull也要设成Off,否则背面的片元不生成阴影。尤其当摄像机在叶片背面时,单面阴影会让地面阴影缺失半边,这个坑很隐蔽。
4.3 抗锯齿:AlphaToCoverage、MSAA和TAA的三难选择
透明度测试最常见的视觉问题就是边缘锯齿。因为片元要么丢弃要么保留,边缘呈现明显的阶梯状。想缓解,有几种手段。
一种是用Alpha To Coverage。它本质上不改变片元着色器的逻辑,而是将片元Alpha值输出到覆盖率掩码,利用MSAA多重采样让边缘像素部分覆盖,达到平滑抗锯齿效果。在URP自定义Shader里,Pass里加一个状态声明AlphaToMask On,然后保证片元着色器输出一个Alpha值(例如texColor.a)即可。它要求Alpha Texture边缘有渐变,不能是硬边。很多移动GPU和部分VR平台对Alpha To Coverage支持不完整,用了可能会在某些设备上有奇怪结果。
另一种是不用依赖MSAA,而是开启TAA(Temporal Anti-aliasing)。URP的TAA核心就是利用时间累积采样,对边缘的闪烁抑制效果很好,但它没法让Cutout边缘本身变平滑,只能减少时间上的闪烁。比如树叶边缘一直在跳,开TAA会稳一些。
第三种是手工做边缘渐变。不要用硬性二值Alpha,而是让Alpha测试区域边缘留一点半透明渐变,然后配合透明度混合,虽然这会引入排序问题,但至少视觉边缘会柔和一些。最常用的做法是在clip之前做一层噪声扰动,让边缘变得不规则,比如草叶边缘被咬过一样,而不是一条直线。代码很简单:
float noise = frac(sin(dot(input.uv, float2(12.9898, 78.233))) * 43758.5453); clip(texColor.a - _Cutoff * (1.0 + (noise - 0.5) * 0.3));这样边缘更加自然,适合草丛、头发片。注意噪声会让Alpha测试结果抖动,如果分辨率抖动明显,需要控制扰动幅度。
5. 性能问题:discard和Early-Z的相爱相杀
5.1 为什么discard会拖慢渲染
现代GPU普遍支持Early-Z,意思是深度测试在很多片元执行片元着色器之前就先做一遍,如果被遮挡就提前丢弃,省掉片元着色器的开销。这是渲染性能的福利之一。但这套机制有一个前提:Shader里不能有discard或者clip,因为如果你把片元丢掉了,那么原本该写入颜色的片元可能不存在,深度测试的结果就需要重新评估。具体到硬件实现,一旦检测到片元着色器可能丢弃片元,就会禁止Early-Z,或者至少在大块区域放弃优化。
结果就是每个片元都得老老实实先跑完片元着色器,哪怕它最终会被丢弃。假如一个场景里有一大片不透明物体,然后你往上面放了一棵树叶密集的树,所有树影区域内的不透明物体片元都要多跑一遍像素级的Alpha采样和clip,带宽消耗成倍上涨。尤其是全覆盖的镂空片,比如一面“半透明”的金属网,后面全都是场景物体,overdraw会非常高。
这里有个优化习惯:把discard判断尽量前置。比如在片元着色器里先采样Alpha,立刻clip,然后再计算光照和其他复杂逻辑。而不要先做一堆光照计算最后才clip,否则浪费更严重。有些Shader为了代码方便会把clip放在PBR光照后,实属大忌。
5.2 移动端Tiled GPU的带宽账单
移动端基本上都是Tiled GPU架构,它们在渲染时会先把一帧分块(Tile)装进On-Chip Memory,再在Tile内执行所有片元操作。这个架构对Early-Z更友好,因为深度和颜色缓冲都在Tile里,速度很快。但discard让Early-Z失效后,所有镂空物体和它们背后的几何体,都会在片上缓冲里被强制访问,让带宽爆炸。
更致命的是,如果你的镂空物体和大量不透明物体重叠,GPU没法只渲染镂空区域的近邻像素,它必须把覆盖该Tile的所有三角形都光栅化,然后逐个片元执行颜色运算。即便最终片元被clip掉了,三角形数据也占用了带宽。遇到过真实案例:一张长满草的平面,地铁休闲游戏里地面占了大半屏幕,结果草叶片元消耗直接让低端机卡成PPT。
怎么破?第一个办法是控制镂空范围的屏幕占比。如果草是稀稀拉拉的,可以尝试用Mesh裁剪掉完全不透明的区域,减少无效片元。第二个办法是尽量用AlphaToCoverage而不是clip,因为AlphaToCoverage在支持MSAA的硬件上可以与Early-Z共存,边缘抗锯齿还更好。第三个办法是如果真的有大面积镂空,考虑用Stencil或者后处理代替,别硬怼片元着色器。
5.3 三个可以立刻用的优化习惯
一是优先使用URP Lit的Alpha Clipping,而不是自写clip,因为内置Shader已经经过许多硬件优化,并且URP会尽可能合并Pass。二是把Cutoff阈值固定,不在运行时动态改,这样材质属性不变,SRP Batcher缓存友好。三是如果必须自写Shader,把纹理采样和clip放在最前面,并且利用LOD指令或者距离裁剪,让远距离的草/树叶直接不渲染,比如让远处物件切到低模或无镂空版本。
顺带说,Unity的Frame Debugger里可以按Pass看到每个DrawCall的Renderer和Shader。如果你发现一个材质在渲染时出现了100万个片元,可以先用Frame Debugger确认Shader变体是否正确,排除掉误开Transparent队列导致的排序和overdraw。
6. 完整实例:一棵URP下的镂空树叶
6.1 素材准备与纹理要求
做一个实际案例最有说服力。素材方面只需要一个平面或Quad模型,和一张RGBA纹理。纹理建议使用相同分辨率的彩色贴图和Alpha贴图,Alpha通道里黑色部分是镂空区域,白色部分是实体。如果你只有一张没有Alpha的普通图片,也可以在PhotoShop或者Krita里新建Alpha通道,用魔棒选中背景填黑,叶子本身填白。注意边缘不要留灰色,灰色会导致半透明效果,在Cutout材质里容易产生白边。边缘最好干净利落。
也可以直接使用Unity的Terrain工具里的草贴图,它们一般自带Alpha。如果不方便找素材,拿笔刷在Alpha通道上画几道也行,反正叶子形状只要大致像就好。这里我建议把贴图Wrap Mode设为Clamp,避免在平面边缘采样出奇怪的重复Alpha。
6.2 带摆动的树叶Shader完整代码
下面给一个完整的、带轻微风吹摆动的树叶Shader。它包含Forward Pass和ShadowCaster Pass,内置Cull Off和双面法线修正,适合直接拿来当草、树叶、纸屑的底子。
Shader "Custom/WavingLeafCutout" { Properties { _MainTex ("Texture", 2D) = "white" {} _Cutoff ("Alpha Cutoff", Range(0,1)) = 0.5 _WindStrength ("Wind Strength", Range(0,1)) = 0.1 _WindSpeed ("Wind Speed", Range(0,5)) = 1.0 } SubShader { Tags { "Queue" = "AlphaTest" "RenderType" = "TransparentCutout" "RenderPipeline" = "UniversalPipeline" } Pass { Name "ForwardLit" Tags { "LightMode" = "UniversalForward" } ZWrite On Cull Off Blend Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float _Cutoff; float _WindStrength; float _WindSpeed; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; }; Varyings vert(Attributes input) { Varyings output; // 简单顶点摆动:基于世界位置和时间的正弦偏移 float3 posOS = input.positionOS.xyz; float3 worldPos = TransformObjectToWorld(posOS); float phase = dot(worldPos.xz, float2(0.5, 0.5)) + _Time.y * _WindSpeed; posOS.x += sin(phase) * _WindStrength; posOS.z += cos(phase * 0.7) * _WindStrength * 0.5; output.positionWS = TransformObjectToWorld(posOS); output.positionHCS = TransformWorldToHClip(output.positionWS); output.normalWS = TransformObjectToWorldNormal(input.normalOS); output.uv = input.uv; return output; } half4 frag(Varyings input) : SV_Target { half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); clip(texColor.a - _Cutoff); // 双面法线修正 float3 geometricNormal = normalize(cross(ddx(input.positionWS), ddy(input.positionWS))); float facing = dot(geometricNormal, input.normalWS) < 0 ? -1 : 1; float3 normalWS = normalize(input.normalWS * facing); // 简单漫反射光照 Light mainLight = GetMainLight(); float ndotl = saturate(dot(normalWS, mainLight.direction)); half3 color = texColor.rgb * mainLight.color * ndotl; return half4(color, 1.0); } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode" = "ShadowCaster" } ZWrite On ZTest LEqual Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float _Cutoff; float _WindStrength; float _WindSpeed; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; float3 posOS = input.positionOS.xyz; float3 worldPos = TransformObjectToWorld(posOS); float phase = dot(worldPos.xz, float2(0.5, 0.5)) + _Time.y * _WindSpeed; posOS.x += sin(phase) * _WindStrength; posOS.z += cos(phase * 0.7) * _WindStrength * 0.5; float3 posWS = TransformObjectToWorld(posOS); output.positionHCS = TransformWorldToShadowCoord(posWS); output.uv = input.uv; return output; } half4 frag(Varyings input) : SV_Target { half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); clip(texColor.a - _Cutoff); return 0; } ENDHLSL } } }这个Shader有几个细节值得注意。第一,Forward Pass里用了Cull Off,所以叶子两面的光照都会计算,但双面法线修正用ddx/ddy几何法线来判断面朝向。第二,顶点动画同时出现在Forward和ShadowCaster里,这样阴影会跟着树叶一起摆动,不会有“影子静止”的违和感。第三,ShadowCaster Pass里的坐标变换用的是TransformWorldToShadowCoord,它内部会处理阴影坐标系,如果换成普通TransformWorldToHClip,阴影位置会偏。
光照部分我只加了简单的漫反射,没加高光和阴影贴图接收。如果你希望树叶也接收场景阴影,可以在frag里用SampleShadowmap或者直接用UniversalFragmentPBR。但为了保持文章聚焦,我觉得这个简化版本已经足够说明问题。
6.3 实际调节效果记录
在使用时,有几个参数建议按项目情况调。_Cutoff直接决定镂空区域大小。0.5是最常用默认值,0.4会更“胖”,0.6会更“瘦”。如果纹理Alpha边缘比较粗糙,建议让Cutoff偏离0太多,否则边缘锯齿会很明显。0.45到0.55之间是甜点区。
_WindStrength建议控制在0到0.2之间,太大会让叶片飞到天上,或者因为顶点偏移导致UV采样区域出现的Alpha错位。_WindSpeed会影响整片叶子摆动频率,如果要做成草丛,每个草块最好单独调成一致的算法,避免整体一起摇晃,观感更自然。如果所有草叶同相位摆动,远看特别假,所以代码里我用dot(worldPos.xz, float2(0.5, 0.5))做了空间位置差异化,简单粗暴但有效。
实际测试时,把Camera的MSAA开成4x,再在URP Asset里勾选HDR,树叶边缘的锯齿会缓解一些。如果项目用了TAA,也可以尝试将AlphaToMask On关掉,因为TAA和AlphaToCoverage同时开启有时会产生边缘闪烁。
7. 常见问题速查表与排查思路
7.1 症状与药方速查表
下面这份表格是我在实际项目里频繁用到的排查清单。遇到“树叶显示不对”之类问题,先对着条目猜,再按顺序检查,通常能快速定位。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 阴影是一块实心矩形/完整轮廓 | ShadowCaster Pass里没有做Alpha测试 | 在ShadowCaster Pass的frag里加clip,或改用URP Lit并勾选Alpha Clipping |
| 物体背面消失 | Cull Back剔除了背面 | 改成Cull Off,同时处理背面法线修正 |
| 边缘发白/发灰 | 纹理Alpha边缘有渐变,或不小心开启了混合 | 清理Alpha通道,保证边缘锐利;关闭Blend |
| 排序混乱、物体前后穿插 | 把镂空物体放到了Transparent队列并关闭了ZWrite | 改成Opaque + Alpha Clipping |
| 使用自定义Shader后SRP Batcher不工作 | 材质属性没放在CBUFFER里,或关键字用了全局multi_compile | 改用CBUFFER_START/END,并用multi_compile_local |
| 移动端边缘闪烁或闪烁 | AlphaToCoverage不被支持,或MSAA未开启 | 关闭AlphaToMask,或换成TAA/边缘噪声扰动 |
| 远处树叶消失或LOD切换穿帮 | LOD级别之间没有对应的剪枝处理 | 优化LOD网格,裁剪掉Alpha接近0的区域,保证LOD层级一致 |
7.2 三个排查工具使用心得
第一个必用的工具是Unity的Frame Debugger。菜单Window > Analysis > Frame Debugger,打开后能看到每个DrawCall执行的Pass、渲染状态、Shader变体。遇到镂空物体不显示或阴影不对,先把Frame Debugger切到对应DrawCall,看看Pass名字是不是LightMode对应的Pass,是否走了ShadowCaster。如果发现DrawCall的Shader显示的是内置/Default,那就要检查URP Asset和Shader的RenderPipeline Tag了。
第二个是RenderDoc。它能直接看到片元着色器里每个变量的实际值,包括纹理采样结果、clip前的Alpha值。你可以在clip那行打断点,或者抓帧看某个像素的Intermediate Value,判断是不是纹理Alpha根本没传进来。很多边缘问题用RenderDoc一查,真相大白。
第三个是Shader Variant。在URP Asset里可以记录Shader变体收集器。如果你勾选了Alpha Clipping,但是变体收集器没把_ALPHATEST_ON关键字收集进去,打包后可能被裁剪掉,在编辑器里正常、手机上莫名没有镂空效果。解决方法是把需要的Shader加入Always Included Shaders,或者用ShaderVariantCollection收集关键变体。
7.3 一个排查故事
说个真实经历。之前做项目,植被阴影在编辑器里一切正常,打包到一台Android真机上却出现了阴影“穿帮”:树的影子是一个完整的半透明方块,完全没镂空。排查了半天,发现问题是Shader里写了_ALPHATEST_ON关键字,但变体收集器没有把包含这个关键字的ShadowCaster变体收集进去。编辑器Progressive Shader编译一切正常,真机用的是Strip Engine Code,结果ShadowCaster Pass里clip被裁掉了。临时解决方案是关掉Strip Engine Code,正式解决方案是把相关变体加进ShaderVariantCollection。这个坑很经典,建议大家变体收集器这块别偷懒。
8. 最后再补几个实战中养成的习惯
做透明度测试时间长了,慢慢攒了一些小习惯。先说贴图,我始终要求美术导出带Alpha通道的TGA或PNG时,边缘别留灰色渐变,宁可使用硬边,因为硬边在Cutout里可控,渐变会产生不可预期的半透明混合。除非你有意做边缘柔和,否则灰色Alpha就是白边源头。
再说Shader结构,我自己写Cutout Shader时有个固定套路:关键字优先,Clip前置,CBUFFER收好属性,ShadowCaster必带,双面处理单独开关。这样一套模板下来,不同项目只要改贴图和参数就行,几乎不用重新调试。如果以后要做PBR植被,直接在这个模板里换成UniversalFragmentPBR,比从零写省力很多。
最后说一句关于调试的心态:透明度测试的效果很受平台影响,你在PC上看到的边缘平滑,跑到移动端可能完全不一样。所以每改一个参数,一定在真机上截屏对比,别只在编辑器里点头叫好。默认编辑器里OpenGL Core和移动端的驱动行为差异,足以让所有努力一夜归零。