Unity VR树叶双面渲染:Simple Alpha Clip Shader性能优化实战

发布时间:2026/10/8 10:38:39
Unity VR树叶双面渲染:Simple Alpha Clip Shader性能优化实战
做 Unity VR 优化做到第四期绕不开的就是树叶双面渲染。我们项目在骁龙 XR2 平台的一体机上做开放场景树一多 GPU 肉眼可见吃紧走近树冠时 fill rate 直接爆掉。树叶单面渲染时玩家绕树走背面叶片直接消失观感很假Simple Alpha Clip Shader 是我们试下来性价比最高的方案之一。这篇文章把树叶双面渲染的几种做法、Simple Alpha Clip 的原理、VR 下的性能权衡以及我实际项目里踩过的坑完整梳理一遍。代码基于 Unity Built-in 管线URP 下思路同样成立只需要把 CGPROGRAM 换成 HLSL 写法、把 LightMode 标签换成 UniversalForward。适合正在性能敏感的 XR 项目里跟植被较劲的 Unity 开发者参考。1. 树叶渲染为什么会成为 VR 项目的重灾区1.1 一张树叶的完整渲染链路很多人一提到植被性能第一反应是面数太多但树叶模型通常只是一个个 Quad 或者几块薄片拼接的卡片顶点量其实可控。真正的开销在像素阶段。以一片 200×300 屏幕像素的叶子卡片为例光栅化之后就有 6 万个像素候选Alpha Clip 虽然最终会丢弃掉其中很大一部分但纹理采样、uv 插值、法线插值这些工作在 discard 之前已经全部执行过了。500 棵树叠下来这个量就很可观了。我们项目最初用的是引擎自带的 Transparent/Cutout/Diffuse 材质它的渲染队列是 AlphaTest对应队列值 2450RenderType 是 TransparentCutout。这套配置本身没问题问题出在它的默认剔除设置上绝大多数自带 Shader 都开启了背面剔除。这直接引出了单面渲染的视觉缺陷。1.2 背面剔除让树叶半张脸消失背面剔除是光栅化阶段的一个标准优化凡是法线方向背离相机的三角形直接不画。对于封闭的凸面物体比如一个球这是数学上绝对正确的——你永远看不到球的背面省下的像素就是白赚的。但树叶不是封闭物体它就是一张薄片不存在背面不可见的逻辑。相机绕到树叶反面时三角形的法线和相机方向基本是反向的于是整个三角形被剔除叶面变成透明的洞。在传统的第三人称游戏里这个问题往往不致命。镜头绝大多数时间保持在角色背后场景设计也会刻意回避让玩家从背面观察树叶的角度。但 VR 完全不同玩家的头部转动是自由的上下左右随意看你根本无法预判他会从哪个角度接近一棵树。我们内测时收到的第一个 bug 反馈就是绕着树走树叶突然全消失了树枝和树干之间能看到天空。这不是模型问题是单面渲染机制决定的必然结果。1.3 VR 双目标渲染把开销放大一倍VR 跟普通游戏还有一个本质区别同一帧要渲染左右眼两张画面。以我们用的骁龙 XR2 平台一体机为例每眼渲染分辨率在 2100×2100 左右刷新率 72Hz。忽略任何遮挡和 overdraw每只眼睛每秒就要填充 2100×2100×72约 3.17 亿像素两只眼睛就是 6.3 亿像素每秒。这个数字还没算树叶卡片互相叠加造成的 overdraw。换句话说普通游戏里那片叶子多画几个像素没关系的心态在 VR 里完全行不通因为每一个多余的像素都要乘上双眼和 72Hz 这两笔账。所以树叶双面渲染在 VR 里不是一个可选项而是必选项只是怎么做、付出多少代价我们需要精打细算。2. 双面渲染的三条路线从 Cull Off 到法线翻转2.1 路线一Cull Off 直接关剔除最简单粗暴的方案在 Shader 里加一行Cull Off。实测下来这一行的效果很直接背面不再被裁剪树叶从任何角度看都在。但它有两个暗坑只看一眼画面容易被忽略。第一个是光照法线错误。叶子卡片只有一条法线方向关闭剔除后渲染背面这条法线仍然指向原来的方向。方向光从正面照过来背面像素计算dot(N, L)时夹角接近 100 度结果要么是负值直接被 clamp 成黑色要么因为漫反射公式里的半兰伯特系数变得灰蒙蒙。你得到的是能看见但一看就是假的背面颜色和正面完全对不上。第二个是阴影 Pass。Unity 渲染阴影贴图时走的是独立的 ShadowCaster Pass不是主 Pass。如果你只改主 Pass 的 Cull阴影 Pass 依然按 Cull Back 执行叶片背面的投影行为跟主 Pass 不一致树影会出现奇怪的半透明缺口。我们测试时还发现Cull Off 之后所有叶片的两面都会尝试写深度多张卡片交叠时深度竞争比单面更激烈半透明排序错误的概率上升。这条路线唯一的适用场景是纯无光照的 NPR 植被或者你的叶子背面颜色就是美术特意画了一层的。2.2 路线二双 Pass 分开画正反面既然 Cull Off 的法线问题出在同一个 Pass 无法区分正反面那就用两个 Pass 来区分第一个 PassCull Back画正面第二个 PassCull Front画背面并且在第二个 Pass 里把法线取反。这个方案有个很重要的优点它不依赖 VFACE 语义OpenGL ES 2.0 时代的老设备也能跑。而且从光栅化角度讲每个像素实际仍然只有一个 Pass 在工作——从正面看第二个 Pass 的所有三角形都被 Cull Front 裁掉了从背面看第一个 Pass 被裁掉。多出来的 Pass 不会让 fragment 数量翻倍。代价主要在 CPU 侧每多一个 Pass 就是多一次 SetPass 调用。场景里树一多SetPass 数量会肉眼可见上涨。我们在 200 棵树的测试场景里对比过双 Pass 版本比单 Pass Cull Off 版本 CPU 开销高了约 10%。如果项目本身 CPU 就吃紧或者你的树叶要开 GPU Instancing这个方案就不太划算。2.3 路线三复制网格 法线翻转第三种做法是在建模阶段就把树叶做成双面几何体一个 Quad 复制一份法线翻转过来两片背靠背贴在一起然后用标准 Shader 直接渲染。视觉上没有任何问题光照正确、阴影正确、排序表现也不错。但这是性价比最低的方案。树冠本来面数就是大头双面几何体直接把顶点数和索引数翻倍对 GPU 顶点着色器和 CPU 的裁剪管线都是实打实的压力。如果树叶还挂了风吹草的顶点动画蒙皮数据和动画权重也要翻一倍。内存占用、加载时间、AssetBundle 体积全部受伤。除非整个场景只有零星几棵需要最高精度的特殊树木否则我不建议在一体机项目里用这条路。2.4 三条路线的选型对比方案背面可见背面光照正确额外几何开销额外 Pass平台要求适用场景Cull Off是否无无任意无光照/NPR 植被双 Pass 正反面是是无1 个GLES2老机型兼容优先复制双面几何是是面数翻倍无任意极少数量高精树木Cull Off VFACE是是无无多数现代设备VR/移动植被主选我们的最终选择是 Cull Off 加 VFACE 组合。它在 Shader 里用一行语义判断正反面既保留了单 Pass 的低 CPU 开销又解决了背面光照错误正好匹配 Simple Alpha Clip Shader 这个主题。3. Simple Alpha Clip Shader 逐行拆解与完整代码3.1 标签与渲染队列的选择Shader 的 Tags 不是随便写的。第一个关键标签是队列Tags { Queue AlphaTest RenderType TransparentCutout IgnoreProjector True }AlphaTest 队列值 2450位于不透明物体2000之后、半透明物体3000之前。选择这个队列是为了让树叶在渲染完普通不透明地面和树干之后先写入深度之后再轮到粒子、水这类真正的半透明物体来做混合。如果把树叶放进 Transparent 队列引擎会启动透明排序按像素中心距离排每张卡片几百片叶子互相穿插时排序会一团乱麻。树叶本质是不希望参与 Alpha Blend 的它要的是要么画、要么丢弃所以 AlphaTest 队列是唯一合理的选择。IgnoreProjector True用来防止投影纹理贴到叶子上。树叶是薄片贴图边缘本来就敏感投影贴图再叠一层会加大 Alpha Test 的误判概率。3.2 主 PassCull Off 加 VFACE 法线翻转下面是完整 Shader 代码我用的命名是 SimpleAlphaClipShader刻意保持了Simple因为它只解决双面叶片 Alpha 裁剪两件事没有引入复杂的 PBR 光照Shader Custom/SimpleAlphaClipShader { Properties { _MainTex (Alpha Texture, 2D) white {} _Color (Color Tint, Color) (1,1,1,1) _Cutoff (Alpha Cutoff, Range(0,1)) 0.5 } SubShader { Tags { Queue AlphaTest RenderType TransparentCutout IgnoreProjector True } Cull Off Pass { Tags { LightMode ForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Cutoff; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float facing : VFACE; }; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; clip(col.a - _Cutoff); float3 N normalize(i.worldNormal); if (i.facing 0.0) { N -N; } float3 L normalize(_WorldSpaceLightPos0.xyz); float ndl saturate(dot(N, L)); float3 diffuse ndl * _LightColor0.rgb unity_AmbientSky.rgb * 0.4; return fixed4(col.rgb * diffuse, 1.0); } ENDCG } } }几个关键点拆开说。Cull Off放在 SubShader 级别意味着这个 SubShader 下的所有 Pass 默认都关闭剔除。如果你后面要在这个 SubShader 里加 ShadowCaster Pass它也会继承 Cull Off——这个特性我们后面会用到。clip(col.a - _Cutoff);是 Alpha Clip 的核心。当采样到的 alpha 小于阈值时返回负值Shader 编译器会生成丢弃片元的指令。很多人习惯写成if (col.a _Cutoff) discard;两者效果几乎一样但 clip 的写法更紧凑也方便把阈值抽成参数。float facing : VFACE;是这套方案的关键。VFACE 是 Unity 支持的一种片元输入语义光栅化阶段会自动填充三角形正面朝镜头时值是正数背面朝镜头时是负数。我们不需要在 vertex 阶段手动给它赋值。片元里判断到i.facing 0.0就把世界法线取反这样背面光照的方向就正常了。diffuse 计算我用了一个简单的半兰伯特加环境光兜底float3 diffuse ndl * _LightColor0.rgb unity_AmbientSky.rgb * 0.4;这样做的目的是防止背面在saturate(dot(N, L))接近 0 时完全死黑。树叶背面本来就是暗部但直接黑成一片会丢失层次加一点环境光之后过渡自然很多。如果你要做 PBR可以把这个公式替换成标准 BRDF但要注意法线翻转必须放在所有光照计算之前否则镜面高光的方向会出问题。3.3 阴影 Pass不加你会后悔一整晚自定义 Shader 最常见的翻车点是主 Pass 看起来完美但树完全不投影。原因很简单阴影贴图渲染时走的是专门的 ShadowCaster Pass你没写这个 Pass引擎没有任何信息来生成你的树影。ShadowCaster Pass 同样要做 Alpha Clip否则阴影贴图会把整张叶子 Quad 视为实心矩形树影变成一大团黑影。代码如下Pass { Name ShadowCaster Tags { LightMode ShadowCaster } Cull Back CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster #include UnityCG.cginc sampler2D _MainTex; float _Cutoff; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; v2f vertShadow(appdata v) { v2f o; o.uv v.uv; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 fragShadow(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); clip(col.a - _Cutoff); SHADOW_CASTER_FRAGMENT(i) } ENDCG }注意一个细节这个 ShadowCaster Pass 我特意改成了Cull Back没有沿用 SubShader 级别的Cull Off。原因不算复杂树叶是一片薄片正面投影和背面投影在阴影贴图上的结果几乎一模一样单面渲染阴影完全够用。而阴影贴图通常要覆盖很大的场景范围省掉背面光栅化等于直接减掉一半阴影 Pass 的填充开销树影视觉上看不出差别。这是我们实测后用好奇换来的一笔稳定收益。TRANSFER_SHADOW_CASTER_NORMALOFFSET和SHADOW_CASTER_FRAGMENT是 UnityCG 里封装好的宏负责把顶点沿着法线方向做一点偏移用来防止阴影痤疮shadow acne。这一步不要省树叶卡片间距本来就小不做 bias 的话阴影里会全是离散的斑点。最后说 FallBack。有人图省事直接用FallBack Legacy Shaders/Transparent/Cutout/Diffuse代替上面的 ShadowCaster Pass。但那个 Fallback Shader 不包含 VFACE 双面优化等于是让阴影走老路线视觉问题会回潮。我建议把它当成出错了不至于紫红色的保险丝核心能力还是要把专门的 ShadowCaster Pass 写在自己的 Shader 里。3.4 参数调节与实际挂载材质挂到树叶卡片上之后第一件事是检查_Cutoff。0.5 是引擎默认值但实际树叶贴图的 alpha 分布千差万别。如果是美术手工画的密集树冠贴图alpha 值往 0.3 调能保住更多细节叶片如果是疏朗的树枝0.5 就挺好。调的时候对着树冠从近到远拉几个距离看重点看边缘有没有掏空感。另外提醒一句不要把这套 Shader 用在封闭单体模型上比如树干圆柱体。树干需要的是 Cull Back不是双面渲染Cull Off 只会让背面像素白白光栅化一次。我们的做法是树干用普通不透明 Shader只有树叶卡片用 SimpleAlphaClipShader。4. VR 特有的性能陷阱Fill Rate、MSAA 与 Alpha 纹理4.1 Fill Rate 是硬天花板在 VR 项目里判断一个渲染特性该不该用第一反应应该是它吃掉多少 Fill Rate而不是它吃掉多少 Draw Call。一体机的 GPU 像素吞吐是硬天花板顶点吞吐反而不是瓶颈。回到开头的数字每眼约 2100×2100、72Hz、双眼渲染每秒都有超过 6 亿像素的基础填充需求。树叶卡片互相重叠overdraw 一旦到 2 以上这个数字立刻翻倍奔着 12 亿去了。你可以在 Profiler 里用 Overdraw 视图看场景颜色发红发白的地方就是 Fill Rate 燃烧最凶的区域树叶树冠往往是整帧里最红的块。理解了这一点再回头看双面渲染Cull Off 让之前完全不被光栅化的背面三角形也进入了流水线理论上每片叶子的填充量接近翻倍。但这笔账要算清楚——你只是在相机能看到背面的那部分叶子上多花了像素而正面看树叶时背面三角形本身就不可见不存在浪费。所以双面渲染真正的成本上限大约等于可见背面叶片数量 × 像素面积远小于全场景树叶 ×2。这也是为什么我最终认可 Cull Off VFACE 路线的理由。4.2 Alpha Clip 不是免费裁剪很多人对 Alpha Test 有个误解觉得反正最后丢了大部分像素应该很省吧。真相是为了判断要不要丢弃fragment shader 至少得先执行到纹理采样那一步。也就是说一个最终被裁掉的像素依然消耗了纹理采样、uv 插值、法线插值和一次 clip 判断。在移动 GPU 上纹理采样的成本占比很高这片叶子卡片的废像素可能花掉正常像素七成的开销。所以优化方向不是多裁一点而是尽量别走到采样。常见的配合手段是深度预写场景先渲染不透明树干和地面写好了深度树叶卡片那些被树干完全遮住的像素会在 Early-Z 阶段被直接拒绝根本走不到采样。我们项目把树的 LOD 也做了分层远处的树先画一个不透明的大树冠替代物近处才挂真正的双面叶片卡片这样近处树叶的废像素能被远处树冠的深度挡住大半。4.3 MSAA 与 Alpha To Coverage一体机 VR 默认会开 MSAA 抗锯齿常见是 2x 或 4x。但 MSAA 对 Alpha Test 材质基本无效你裁掉的是片元而 MSAA 只对颜色边界做覆盖率混合Alpha 边界依然是硬切割远处树叶锯齿非常明显。一个更对症的方案是 Alpha To Coverage。在 ShaderLab 的 SubShader 里开启AlphaToMask On它会把片元的 alpha 值转换成 MSAA 采样点的覆盖率alpha 越低覆盖的采样点越少边缘就出现了半透明的渐进过渡。只要你的树叶贴图在边缘有 1~2 像素的 alpha 渐变视觉上立刻平滑很多。但注意两点。第一它必须配合 MSAA 开启MSAA 关掉时 AlphaToMask 是一个空操作。第二它和clip(col.a - _Cutoff)是互斥的clip 会把 alpha 变成 0 或 1 的硬阈值AlphaToMask 的过渡完全失效。我们实际做法是项目里提供两个 Shader 变体质量档开启 AlphaToMask性能档保留 Alpha Clip。4.4 阴影和纹理的隐藏账单双面渲染上线的第一个星期我们吃掉了一笔隐蔽的性能债树影方向光打开后树叶会在自己身上投出密密麻麻的小黑点。这是双面卡片投影到自身导致的。解决方式是给阴影 Pass 加大 normal bias我们把_ShadowBias从默认 0.05 调到 0.2斑点明显缓解。代价是阴影会轻微漂移树冠底部和地面相接处需要测试几次才能找到视觉可接受的点。纹理侧的坑更实在。移动端默认用 ASTC 压缩ASTC 6x6 对 RGB 影响不大但对 alpha 通道的压缩损失很明显。alpha 边缘一旦压缩变形Cull Off clip 就会在叶片边缘产生断断续续的溶洞。我们项目里近景 LOD 的树叶贴图单独设置为 ASTC 4x4中远景 LOD 用 6x6内存多花了一点但边缘干净程度完全不是一个级别。5. 实战踩坑记录白边、闪烁与 Profiler 排障5.1 白边mipmap 的透明像素混色新 Shader 上线后的第一个视觉 bug 是树叶边缘一圈白边近看没事往后退到 5 米左右开始出现越远越明显。排查链路Frame Debugger 逐帧看树叶的 fragment 输出发现白边像素的 alpha 集中在 0.3~0.6 之间颜色却非常亮。再查贴图问题不在 Shader 在资源树叶贴图的透明区域 RGB 值是纯白而不是叶子本身的颜色。mipmap 生成时把叶子边缘的绿色像素和透明区域的白色像素混在一起mip 层级越深混出的颜色越偏灰白。这些像素 alpha 落在阈值附近clip 有时候裁掉有时候保留保留下来就是一圈亮边。修复方案是给贴图做透明区域边缘扩展在出图工具里把 alpha0 的区域填上邻近叶片的平均颜色至少扩展 4 像素。这样 mipmap 混出来的边缘颜色就会跟叶片本体一致白边自然消失。另外可以勾选 Unity 纹理导入器里的 Alpha Is Transparency它会让引擎在生成 mip 时对 alpha 和 RGB 做特殊处理防止混色。这个选项我们后来成了树叶贴图的标准配置。5.2 头部转动时叶片闪烁软化的 Alpha Clip上线后内测反馈了一个很磨人的问题戴着 VR 头显慢慢转头近处的叶子边缘会出现像素级的闪烁像贴图在切换。这类闪烁的原理是 mipmap 分层切换时alpha 渐变区域的阈值判断在保留和丢弃之间来回跳。普通屏幕游戏里不太显眼VR 近眼屏放大后直接成了噪点。我们采用的方案是用fwidth软化裁剪阈值float edge fwidth(col.a) * 0.5; clip(col.a - (_Cutoff - edge));fwidth 计算的是当前屏幕像素上 alpha 的导数边缘变化越快这个值越大相当于让阈值跟着贴图缩放动态变化。这样做之后叶片边缘不再是全有全无的硬切fragment 的丢弃/保留会平滑过渡一层闪烁大幅度减弱。代价是边缘会略微泛透和 AlphaToMask 的效果有点接近。如果项目已经开了 AlphaToMask就用不到这个手工软化了。5.3 Profiler 排障链从 55fps 回到 72fps树叶双面渲染改完之后我们在 500 棵树的测试场景跑了一遍帧率从稳定 72 掉到了 55 左右这是一次典型的大型回归。排查过程值得记录。第一步开 Unity Profiler看 GPU 行。Frame 时间 18ms里面最大的一块是 RenderForward.SetPass 和阴影渲染。Frame Debugger 展开后树叶材质贡献了 100 多次 SetPass。问题很清楚树叶卡片没有做合批每棵树的几百片叶子各算一个渲染项。第二步给树叶卡片开 GPU Instancing。材质面板勾选 InstancingShader 里加#pragma multi_compile_instancing然后确保树叶 Mesh 使用相同材质和纹理。这一步把可见树叶的 SetPass 从 143 砍到了 97。第三步动阴影。ShadowCaster Pass 改成 Cull Back阴影距离从 100 米缩到 60 米阴影贴图分辨率从 2048 降到 1536。三件事叠在一起省了大约 2.1ms。第四步近景 LOD 开 AlphaToMask远景 LOD 保留 Alpha Clip。近处平滑、远处省 Fill Rate视觉和性能两头占。三门调整做完平均帧耗时从 16.8ms 降到 13.2ms稳定回到 72fps刷新率不再掉档。这说明双面渲染本身并不贵贵的是双面渲染没有配套的合批与阴影优化时被放大出来的开销。5.4 合批边界VFACE 与 Instancing 的兼容性有一个容易忽略的点VFACE 语义会不会破坏 GPU Instancing 合批实测是不会。VFACE 是光栅化阶段生成的数据每个片元独立不依赖 instance 属性。我们最终 Shader 同时使用了 GPU Instancing 和 VFACEFrame Debugger 确认树叶是合批渲染的。如果你发现合批没生效优先怀疑树叶 Mesh 之间是否共享同一个材质实例或者贴图有没有散装引用而不是怀疑 VFACE。6. 实测数据与后续优化方向6.1 测试场景与前后数据整理一下我们这次优化的实测数据测试条件骁龙 XR2 平台一体机72Hz 目标帧率500 棵树的开放场景相机只看森林方向光照为一张 2048 方向光阴影贴图。指标优化前优化后平均帧耗时16.8ms13.2ms树冠渲染 GPU 耗时6.1ms3.6msOverdraw相机视角2.71.9可见 SetPass 调用14397可见 DrawCall211148帧率稳定性55~72fps 波动稳定 72fps收益主要来自三个方向阴影 Pass 单面化、树叶卡片 GPU Instancing、远近 LOD 分层。双面渲染本身的成本占比其实很小这印证了前面说的观点——双面显示不该成为性能恐惧症该做还是得做。真正吃性能的是双面渲染 双面阴影 不合批这个组合拳。6.2 更进一步的思路Dither 过渡与 Alpha To Coverage如果你不想跟 MSAA 纠缠或者设备不支持 AlphaToMask还有一个更激进的方案用 Bayer 抖动矩阵代替 clip 阈值。fixed4 col tex2D(_MainTex, i.uv); float dither Bayer4x4(i.uv * _ScreenParams.xy); clip(col.a - dither);Bayer 抖动会让边缘变成棋盘格状的离散点阵视觉上接近半透明效果且不需要 MSAA 就能实现平滑过渡。缺点是远景叶子会有一层明显的颗粒噪感在 VR 里尤其显眼。我们的处理是只在最远距离的 LOD 上使用抖动版本这个距离的噪声混在远处树冠的密集纹理里肉眼基本分辨不出。6.3 我的选型建议最后给一个可以直接抄作业的决策路径。如果树叶是纯无光照的 stylized 风格Cull Off一行就能收工。如果要走 PBR 光照且需要双面直接上本文这套 Cull Off VFACE ShadowCaster Cull Back 的版本阴影和主 Pass 分开控制。如果项目还在支持 OpenGL ES 2 老设备退回双 Pass 写法多花一点 SetPass 换兼容性。如果设备支持 AlphaToMask近景 LOD 用它替代手工 clip能省掉一大片 fwidth 调参的精力。我个人的实际体会是树叶双面渲染这个需求90% 的工作量不在 Shader而在资源管理和阴影设置。贴图 alpha 质量、mipmap 混色、阴影 bias、合批策略每一样都比如何关闭剔除更影响最终效果。代码只要认准 VFACE 和 Cull Off 这一个组合后面就是在光照、阴影、纹理三个坑之间来回填土。真遇到树叶白边或者闪烁别急着改 Shader先用 Frame Debugger 看看 fragment 输出的 alpha 和 RGB问题多半出在贴图资源而不是渲染代码上。