
1. 树叶渲染在VR项目里为什么是个隐形性能杀手做过VR项目的人大概都有过这种体验场景里放了几棵树PC端编辑器里跑得飞起一打包到一体机头显里帧率直接从72掉到45画面开始抖动用户戴上十分钟就晕。排查半天最后发现罪魁祸首不是模型面数也不是实时光照而是那几片看起来人畜无害的树叶。树叶这个东西在VR里特别尴尬。它有几个天然属性决定了它难搞第一树叶几乎都是面片Plane/Quad拼出来的不是实体模型所以每一片叶子你都得考虑从正面看和从背面看的问题第二树叶的贴图几乎都带Alpha通道要么是整片叶子的轮廓镂空要么是叶脉细节这意味着你不能用普通的不透明Shader第三VR是双目渲染同一片树叶在一个帧里要被画两次左右眼各一次如果Shader里有一点浪费代价直接翻倍。很多人的第一反应是直接用Unity内置的Standard Shader把Rendering Mode切成Cutout再勾上双面。这个方案在PC上没问题但在移动端VR一体机上Standard Shader的变体数量和计算量都偏重尤其是它默认会走完整的PBR光照链路对于树叶这种大面积、低精度需求的物体来说属于典型的杀鸡用牛刀。更麻烦的是Standard Shader的双面渲染是通过Cull Off实现的但它并没有针对背面法线做翻转处理导致背面受光完全错误树叶背面要么死黑一片要么亮得诡异。所以这篇要聊的就是怎么用自定义Shader把树叶的双面渲染和Alpha Clip做扎实同时把性能压到VR能接受的范围。核心思路是用最简的Shader完成正面正常、背面翻转法线、Alpha裁剪、无多余光照计算这四件事。这套方案我在几个Quest和Pico的一体机项目里都跑过配合合理的LOD和合批策略树叶部分的Draw Call和GPU耗时都能压到比较舒服的水平。下面我会从双面渲染的底层机制讲起然后给出完整的Simple Alpha Clip Shader代码再聊合批、法线、阴影这几个容易翻车的点最后说说VR里树叶渲染的实测调优经验。内容偏实战代码可以直接抄但每个参数为什么这么设我会讲清楚。2. 双面渲染的三种实现路径与VR场景下的取舍2.1 Cull Off、两个Pass、还是几何着色器Unity里实现双面渲染主流有三条路我先把它们摆出来对比再说为什么VR场景下我最终选了Cull Off这条路。方案实现方式优点缺点VR适用性Cull Off在Pass里写Cull Off代码最简一个Pass搞定背面法线需手动翻转光照易错高配合法线翻转即可双Pass一个Pass画正面一个Pass画背面正背面可分别处理Draw Call翻倍VR下代价大低几何着色器Geometry Shader里复制并翻转图元逻辑集中移动端GPU支持差性能差极低先说几何着色器。这东西在桌面端看着很优雅一个Shader里把三角形复制一份、翻转法线、再输出理论上完美。但移动端GPU尤其是高通Adreno和Mali系列对Geometry Shader的支持一直很勉强很多机型直接不支持就算支持性能也是灾难级的。VR一体机基本都是移动端芯片这条路直接排除。再说双Pass。它的好处是正反面可以走完全不同的逻辑比如正面用一套光照、背面用另一套。但问题是每个Pass都是一次完整的绘制树叶这种数量巨大的物体Draw Call直接翻倍。VR里CPU本来就紧张双目渲染还要再乘2双Pass方案在树叶上基本等于自杀。除非你有非常特殊的背面表现需求否则不推荐。最后是Cull Off。它只用一个PassGPU在光栅化阶段不剔除背面三角形正反面都画。代价是背面像素的着色器还是会跑一遍但因为只有一个PassDraw Call不增加整体可控。关键问题是背面三角形的法线是朝外的相对于观察者来说是背对的如果不处理光照计算会完全错误。解决办法是在片元着色器里判断当前是不是背面如果是就把法线翻转。判断正背面有个经典技巧用VFACE在HLSL里叫SV_IsFrontFace语义或者用视线方向与法线的点积来判断。前者更准确后者兼容性更好。我在移动端一般用点积法因为VFACE在某些老驱动上有坑。2.2 为什么VR里背面像素的代价不能忽视这里要展开讲一个很多人忽略的点Cull Off之后背面像素的片元着色器照样要执行。也就是说如果一片树叶正面朝你你看不到它的背面但GPU仍然会为那些背面三角形跑一遍片元着色器除非它们被深度测试提前干掉但树叶是半透明的Alpha Clip深度写入有讲究后面细说。在PC上这可能无所谓但在VR一体机上树叶往往占屏幕面积很大背面像素的浪费可能占到树叶总像素的30%到50%。所以我们的Shader要尽量轻片元里能少算就少算。这也是为什么我不建议在树叶Shader里塞PBR、塞复杂光照模型——你等于把这份开销乘以了一个大于1的系数。一个实用的优化是在片元着色器最开头就做Alpha Clip如果这个像素被裁掉了后面的光照计算全部跳过。GPU的早期深度测试和这个配合能省下大量无效计算。顺序很重要先Clip再算光照别反过来。2.3 法线翻转的正确姿势背面法线翻转标准做法是// 判断是否背面 float facing dot(normalize(i.normal), normalize(i.viewDir)); // 如果是背面翻转法线 if (facing 0) { i.normal -i.normal; }但这里有个细节i.normal在插值之后可能已经不是单位向量了所以要先normalize。另外viewDir的计算方式也要注意是世界空间视线方向还是切线空间要和你的光照模型匹配。更简洁的写法是用SV_IsFrontFacefloat faceSign i.faceSign; // 由SV_IsFrontFace传入正面为1背面为-1 i.normal * faceSign;这个在DX11及以上、Metal、Vulkan上都支持移动端现代GPU基本没问题。但如果你要兼容非常老的设备还是用点积法稳妥。我个人在项目里的做法是默认用SV_IsFrontFace在Shader的Fallback里放一个点积法的版本这样新设备走快路径老设备自动降级。3. Simple Alpha Clip Shader的完整实现与逐行拆解3.1 完整Shader代码先上代码这是我在多个VR项目里实际使用的版本基于Unity的Built-in管线如果你用URP逻辑一样只是光照函数要换。Shader Custom/VR/SimpleAlphaClipDoubleSided { Properties { _MainTex (Albedo (RGB) Alpha (A), 2D) white {} _Color (Tint Color, Color) (1,1,1,1) _Cutoff (Alpha Cutoff, Range(0,1)) 0.5 _AmbientBoost (Ambient Boost, Range(0,2)) 1.0 } SubShader { Tags { Queue AlphaTest RenderType TransparentCutout IgnoreProjector True } Cull Off ZWrite On ZTest LEqual Pass { Tags { LightMode ForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #pragma multi_compile_fog #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Cutoff; float _AmbientBoost; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; float faceSign : TEXCOORD3; UNITY_FOG_COORDS(4) SHADOW_COORDS(5) }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.faceSign 1.0; UNITY_TRANSFER_FOG(o, o.pos); TRANSFER_SHADOW(o); return o; } fixed4 frag (v2f i, fixed facing : VFACE) : SV_Target { // 1. 先采样并做Alpha Clip尽早剔除 fixed4 tex tex2D(_MainTex, i.uv); fixed4 col tex * _Color; clip(col.a - _Cutoff); // 2. 背面法线翻转 float3 normal normalize(i.worldNormal); normal * facing; // 3. 最简光照主方向光 环境光 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float ndotl saturate(dot(normal, lightDir)); fixed3 diffuse _LightColor0.rgb * ndotl; fixed3 ambient ShadeSH9(float4(normal, 1.0)) * _AmbientBoost; fixed3 finalColor col.rgb * (diffuse ambient); // 4. 阴影 fixed shadow SHADOW_ATTENUATION(i); finalColor * shadow; // 5. 雾 UNITY_APPLY_FOG(i.fogCoord, finalColor); return fixed4(finalColor, 1.0); } ENDCG } // 阴影投射Pass Pass { Tags { LightMode ShadowCaster } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; float _Cutoff; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; v2f vert (appdata_base v) { v2f o; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) o.uv TRANSFORM_TEX(v.texcoord, _MainTex); return o; } float4 frag (v2f i) : SV_Target { fixed4 tex tex2D(_MainTex, i.uv); clip(tex.a - _Cutoff); SHADOW_CASTER_FRAGMENT(i) } ENDCG } } Fallback Mobile/VertexLit }3.2 关键行逐条解释Cull Off这一行是整个双面渲染的核心。它告诉GPU不要剔除背面三角形。注意它写在SubShader层级对两个Pass都生效。ZWrite OnQueue AlphaTest树叶用Alpha Clip而不是Alpha Blend所以深度写入要开。队列用AlphaTest2450保证在不透明物体之后、透明物体之前渲染。这个顺序对VR很重要因为不透明物体先画能最大化早期深度测试的收益。clip(col.a - _Cutoff)这是Alpha Clip的核心。clip函数在参数小于0时丢弃当前像素。放在片元着色器最前面能让被裁掉的像素尽早退出省下后面的光照计算。fixed facing : VFACE这是片元着色器的输入语义正面传入1背面传入-1。用它来翻转法线比点积法更准确因为它直接来自光栅化阶段不受插值误差影响。normal * facing翻转法线。注意这里normal已经normalize过了翻转后仍然是单位向量。ShadeSH9这是Unity内置的球谐环境光函数比直接用一个固定环境光颜色要真实而且开销很小。_AmbientBoost参数用来手动调节环境光强度因为VR里环境光太暗会让树叶看起来发灰。阴影Pass这个Pass负责让树叶投射阴影。注意它也要做Alpha Clip否则树叶的阴影会是一个完整的方块非常出戏。TRANSFER_SHADOW_CASTER_NORMALOFFSET会自动处理法线偏移减少阴影痤疮。3.3 为什么不用Surface ShaderUnity的Surface Shader写起来确实方便几行就能搞定光照。但我不推荐在VR树叶上用原因有三个第一Surface Shader会生成大量变体编译出来的Shader体积大运行时切换变体可能造成卡顿。VR对帧率稳定性要求极高一次卡顿就是一次眩晕。第二Surface Shader的光照模型是封装好的你很难精确控制哪些计算发生、哪些不发生。树叶这种需要极简光照的物体用Surface Shader等于放弃了优化空间。第三Surface Shader的Cull Off配合法线翻转写起来反而更绕因为你要在surf函数里处理worldNormal而VFACE在Surface Shader里不太好拿。所以我的建议是树叶、草、粒子这类需要极致优化的物体一律手写Vertex/Fragment Shader。代码长一点但每一行你都知道它在干什么。4. 合批、法线与阴影树叶渲染的三个翻车重灾区4.1 动态合批为什么对树叶基本无效很多人想着树叶数量多靠Unity的动态合批来降Draw Call。但动态合批有几个硬性条件顶点数不能超过300老版本是900不能用缩放材质必须相同。树叶面片虽然顶点少但一旦你给树叶加了风吹动画顶点动画动态合批直接失效因为顶点位置在Shader里被改了Unity的合批是在CPU端做的它不知道你Shader里干了什么。那静态合批呢静态合批要求物体不能移动。树叶如果要做风吹效果就不能用静态合批。如果你的树叶是静态的比如远景树那静态合批是有效的但要注意静态合批会把所有树叶合并成一个大Mesh内存占用会上升而且视锥剔除会失效——一棵树在屏幕外整片树林的合批Mesh还是会被提交。真正对树叶有效的是GPU Instancing。只要你的树叶用的是同一个Mesh和同一个材质开启#pragma multi_compile_instancingUnity就能用一次Draw Call画几百片树叶。这个在VR里收益巨大。但Instancing有个限制每个实例的材质属性可以不同通过UNITY_INSTANCING_BUFFER但贴图必须相同。所以如果你有多种树叶贴图要么做成图集要么分组Instancing。我在项目里的做法是近景树叶用GPU Instancing远景树叶烘焙成一张大贴图或者用Impostor。这样近处有细节远处不费性能。4.2 法线贴图在双面渲染下的坑如果你给树叶加了法线贴图双面渲染会变得更复杂。因为法线贴图是在切线空间里的背面三角形的切线空间和正面是镜像的。如果你不处理背面的法线贴图会看起来反了凹凸方向完全错误。解决办法是在采样法线贴图后根据facing翻转切线空间的X分量或者Y分量取决于你的切线空间约定float3 normalTS UnpackNormal(tex2D(_NormalMap, i.uv)); normalTS.xy * facing; // 翻转切线空间但说实话VR里的树叶我基本不建议用法线贴图。原因很简单VR头显的分辨率有限树叶在屏幕上占的像素本来就不多法线贴图带来的细节提升很有限但采样开销和切线空间计算是实打实的。省下这个开销把预算留给更重要的地方比如抗锯齿、分辨率体验提升更明显。如果你非要用那就用上面那行代码处理翻转别偷懒。4.3 阴影树叶阴影为什么总是糊成一团树叶的阴影是个老大难。默认情况下Unity的阴影贴图分辨率有限树叶这种带Alpha Clip的物体阴影边缘会非常粗糙远看就是一团黑。有几个改善方向第一提高阴影贴图分辨率。但这会直接增加显存和带宽开销VR里要谨慎。我的经验是主方向光的阴影贴图不要超过2048再高收益递减明显。第二用阴影距离Shadow Distance控制。把阴影距离调近让只有近处的树叶投射阴影远处的树叶不参与阴影计算。这个在Quality Settings里设置对性能帮助很大。第三在ShadowCaster Pass里做Alpha Clip。这个前面代码里已经做了但很多人会忘。如果不做树叶阴影就是一个完整的方块比糊还难看。第四考虑用屏幕空间阴影或者接触阴影。这个在URP里支持得比较好Built-in管线要自己写。VR里屏幕空间阴影开销不小要权衡。我个人的经验是树叶阴影在VR里宁可少一点也不要糊。把阴影距离调近让近处树叶有清晰的阴影远处树叶干脆不投影视觉上反而更干净。5. VR实测从72帧到90帧的树叶调优记录5.1 测试环境与基线数据先交代一下测试环境方便你对照自己的项目。设备是Quest 2和Pico 4Unity版本2021.3 LTSBuilt-in管线目标帧率72HzQuest 2默认。场景里有一片约200棵树的树林每棵树约500片树叶面片总计约10万片树叶。基线方案用的是Standard Shader Cutout 双面实测数据指标基线Standard优化后Simple Alpha ClipDraw Call树叶部分约320约12GPU InstancingGPU耗时树叶部分4.8ms1.9msCPU耗时树叶部分3.2ms0.8ms整体帧率58-65fps稳定72fps这个提升主要来自三块Shader简化、GPU Instancing、以及Alpha Clip提前剔除。5.2 逐步调优的过程第一步替换Shader。把Standard换成上面的Simple Alpha Clip Shader。这一步GPU耗时从4.8ms降到3.1ms主要省在光照计算上。Standard的PBR链路对树叶来说太重了。第二步开启GPU Instancing。在Shader里加#pragma multi_compile_instancing然后在材质上勾选Enable GPU Instancing。Draw Call从320降到12CPU耗时从3.2ms降到0.8ms。这一步对CPU的解放非常明显VR里CPU往往是瓶颈。第三步调整Alpha Cutoff。默认0.5我调到0.35。为什么因为树叶贴图的边缘往往有半透明过渡Cutoff太高会让树叶看起来缺角太低会让边缘出现锯齿。0.35是我在Quest 2上试出来的平衡点。这个值要根据你的贴图实际调整没有万能值。第四步关闭法线贴图。前面说过VR里树叶法线贴图收益低。关掉之后GPU耗时又降了0.3ms左右。第五步优化阴影。把阴影距离从50米调到30米阴影贴图从2048降到1024。这一步GPU耗时降了约0.5ms视觉上近处树叶阴影依然清晰远处树叶不投影反而更干净。最终稳定在72fpsGPU耗时1.9msCPU耗时0.8ms留出了足够的余量应对复杂场景。5.3 几个反直觉的发现调优过程中有几个发现和直觉相反值得单独说。发现一Alpha Clip的Cutoff值对性能有影响。Cutoff越高被裁掉的像素越多片元着色器执行得越少性能越好。但Cutoff太高树叶会缺角。所以这是一个性能和画质的权衡不是单纯调高就好。我的做法是在画质可接受的范围内尽量调高Cutoff。发现二ZWrite On不一定比ZWrite Off慢。很多人觉得ZWrite On会增加带宽开销但在树叶这种大量重叠的场景里ZWrite On能让后面的像素被早期深度测试干掉反而可能更快。我实测下来ZWrite On比ZWrite Off在树叶场景里GPU耗时低约0.4ms。当然这取决于场景要实测。发现三GPU Instancing不是万能的。如果树叶的缩放不一致Instancing的合批会失效因为Unity的Instancing对非统一缩放支持不好。所以树叶的缩放要统一或者把缩放烘焙到Mesh里。6. 树叶Shader的进阶玩法与边界情况6.1 风吹动画怎么加才不影响Instancing树叶不动就像塑料片风吹动画几乎是必须的。但前面说过顶点动画会让动态合批失效。不过GPU Instancing不受影响因为Instancing是在GPU端做的顶点动画也在GPU端两者可以共存。加风吹动画的常见做法是在顶点着色器里根据时间和顶点位置偏移float3 windOffset float3( sin(_Time.y * _WindSpeed v.vertex.x * _WindFrequency), 0, cos(_Time.y * _WindSpeed v.vertex.z * _WindFrequency) ) * _WindStrength * v.vertex.y; // 假设y是高度越高摆动越大 v.vertex.xyz windOffset;注意v.vertex.y这个因子它让树叶根部不动、顶部摆动看起来更自然。这个因子可以用顶点色或者UV的某个通道来存比用位置更灵活。关键点风吹动画要在UnityObjectToClipPos之前做而且要用物体空间的位置不要用世界空间否则Instancing会出问题。6.2 双面渲染下的光照一致性双面渲染有个容易被忽略的问题正面和背面的光照应该看起来是同一片叶子的两面而不是两个独立的物体。如果背面法线翻转后光照方向和正面完全相反叶子看起来会很薄像纸片。解决办法是背面不要用完全翻转的法线而是用一个半翻转的法线。具体来说可以把背面法线向视线方向插值float3 normal normalize(i.worldNormal); float3 viewDir normalize(_WorldSpaceCameraPos - i.worldPos); normal lerp(normal, -normal, facing * 0.5 0.5); // 或者更简单背面法线向视线方向偏一点 if (facing 0) { normal normalize(lerp(-normal, viewDir, 0.3)); }这样背面看起来会有一点透光的感觉更像真实的树叶。这个技巧在卡通渲染里也常用叫背面光或者透射光。6.3 什么时候该放弃双面渲染双面渲染虽然好但不是所有树叶都需要。如果你的树叶是实体模型比如用建模软件挤出的有厚度的叶子那就不需要双面渲染直接Cull Back就行性能更好。另外如果树叶在屏幕上很小远景双面渲染的视觉收益几乎为零这时候可以用单面渲染 稍微放大面片来模拟省下一半的片元着色器开销。我的经验是近景树叶双面中景树叶单面但加一点背面光远景树叶直接烘焙成Billboard。这样每一档都有对应的优化策略整体性能最优。7. 我在VR树叶渲染上踩过的几个真实坑7.1 坑一VFACE在部分安卓机型上返回错误值前面代码里用了VFACE语义这个在大多数设备上没问题但我在一个搭载老款Mali GPU的设备上遇到过VFACE始终返回1的情况导致背面法线不翻转树叶背面全黑。排查过程很痛苦因为编辑器里完全正常只有真机有问题。后来查资料发现是那个GPU驱动的bug。解决办法是加一个Fallback用点积法判断正背面float facing dot(normalize(i.worldNormal), normalize(i.viewDir)) 0 ? 1 : -1;这个虽然不如VFACE准确在法线接近垂直于视线时会抖动但兼容性好。我的做法是在Shader里用#if defined(SHADER_API_GLES)之类的宏来区分但更简单的做法是直接用点积法反正树叶对精度要求不高。7.2 坑二Alpha Clip导致的Z-Fighting树叶面片如果重叠得很近Alpha Clip ZWrite On会导致Z-Fighting表现为树叶边缘闪烁。这个问题在VR里特别明显因为双目渲染会放大闪烁感。解决办法有两个一是给树叶面片之间留出足够的距离不要贴得太近二是用Alpha To Coverage代替Alpha Clip。Alpha To Coverage利用MSAA的采样点来做透明边缘更平滑而且不会有Z-Fighting。但Alpha To Coverage需要开启MSAAVR里MSAA开销不小要权衡。我最后的方案是近景树叶用Alpha To Coverage 2x MSAA远景树叶用Alpha Clip。这样近处画质好远处性能好。7.3 坑三GPU Instancing下的阴影错位开启GPU Instancing后我发现树叶的阴影位置偏了和树叶本身对不上。排查后发现是ShadowCaster Pass里没有正确处理Instancing的变换矩阵。解决办法是在ShadowCaster Pass里也加上Instancing的支持#pragma multi_compile_instancing // 在顶点着色器里 UNITY_SETUP_INSTANCE_ID(v); UNITY_TRANSFER_INSTANCE_ID(v, o);这个坑很隐蔽因为阴影错位不是每次都出现只有在特定角度和距离下才明显。我建议只要用了Instancing就把所有Pass都加上Instancing支持别偷懒。7.4 坑四树叶贴图的Mipmap导致的边缘闪烁树叶贴图如果开了Mipmap在远处会因为Mipmap层级切换导致Alpha边缘闪烁。这是因为Mipmap会平均Alpha值导致远处的树叶Alpha值在Cutoff附近抖动。解决办法是给树叶贴图的Alpha通道做Alpha Test专用的Mipmap处理或者在Shader里手动计算Mipmap层级对Alpha做特殊处理。Unity有个UnpackNormal类似的技巧但更简单的做法是树叶贴图关闭Mipmap用各向异性过滤来补偿。这个在VR里效果不错因为VR的视角变化快Mipmap的收益本来就不如PC明显。8. 写在最后树叶优化没有银弹树叶渲染这件事我从最早用Standard Shader硬扛到后来手写Shader、上Instancing、调Alpha To Coverage前后折腾了好几个项目。最大的体会是没有一套参数能通吃所有场景。Quest 2上跑得好的配置换到Pico 4上可能就要调近景树叶的方案远景直接套用就是浪费。所以我的建议是把上面这套Shader当作一个起点而不是终点。先把它跑起来然后根据你的项目实际情况一项一项地调——Cutoff值、阴影距离、Instancing开关、Alpha To Coverage开关。每调一项用Profiler看数据用头显看画质。数据说话别凭感觉。另外VR项目里树叶往往不是性能瓶颈的全部。我见过太多项目把树叶优化到极致结果发现瓶颈在UI或者后处理上。所以优化要有全局观先定位真正的瓶颈再动手。树叶优化只是其中一环别把它当成万能药。最后分享一个我常用的小技巧在场景里放一个树叶压力测试区密集地摆上几百棵树专门用来测树叶性能。每次改完Shader或者参数先在这个区域跑一遍数据稳定了再放到正式场景里。这样能快速迭代不用每次都加载完整场景。这个习惯帮我省了大量时间推荐你也试试。