URP透明度测试:从原理到Shader实现的完整指南

发布时间:2026/10/3 1:44:25
URP透明度测试:从原理到Shader实现的完整指南 做渲染的老哥们应该都有印象一聊到植被、铁丝网、头发、甚至飘落的纸屑基本上就是在和“抠图”作斗争。这个抠图操作在渲染流水线里有个专门的名字透明度测试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。透明度测试通常走QueueAlphaTest或者QueueGeometryZWrite开启Blend关闭透明度混合通常走QueueTransparentZWrite关闭Blend开启。URP的Lit材质里也体现了这个区分Surface Type选Opaque再勾选Alpha Clipping和Surface Type选Transparent二者背后是两条完全不同的渲染路径。2. 一个最简URP Unlit透明测试Shader2.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视为自己的不会回退到内置管线。QueueAlphaTest和RenderTypeTransparentCutout帮助Unity在渲染顺序和着色器替换方面正确处理它后者对面片高亮、阴影接收等内置功能很重要。Pass里Tags { LightMode UniversalForward }是URP前向渲染的主Pass标识。没有这个TagURP不认你这个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如果你不想自己写ShaderURP内置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 ClippingLit的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而是开启TAATemporal 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和移动端的驱动行为差异足以让所有努力一夜归零。