Unity手写Shader实战:从Shader Graph性能翻车到贴图混合、描边与后处理
1. 为什么我放弃了连连看重新用代码手写Shader1.1 从一次性能翻车说起前阵子帮一个朋友优化他们团队的一个二次元风格手游项目角色渲染用的是Shader Graph搭出来的卡通着色器。编辑器里跑得好好的打包到真机上直接掉帧中低端机型从60帧掉到30帧出头。我打开Frame Debugger一看好家伙一个角色身上叠了七八个Pass每个Pass里还有一堆Branch节点GPU上的实际指令数比手写版本多了将近一倍。这不是个例。可视化节点编辑器确实降低了入门门槛拖拖拽拽就能出效果但它有几个绕不开的问题一是生成的代码不够精简节点之间的中间计算往往没法被编译器完全优化掉二是复杂逻辑用节点连线会变成一团乱麻维护成本极高三是很多平台特定的优化手段比如#pragma multi_compile、UNITY_INSTANCING_BUFFER这些节点编辑器要么不支持要么支持得很别扭。所以我又回到了纯代码写Shader这条路。Cg/HLSL虽然看起来门槛高但一旦你理解了它的结构写起来其实比拖节点快得多而且性能可控、逻辑清晰、版本管理友好。这篇文章就把我从基础着色器到贴图混合、描边、后处理的完整实战经验整理出来适合有一定Unity基础、想从Shader Graph转向手写Shader的开发者也适合刚接触图形学想搞明白底层原理的朋友。1.2 手写Shader到底难在哪很多人觉得手写Shader难其实难的不是语法而是这几个东西语义绑定Semantic搞不清楚哪个变量该用什么修饰符、坐标系变换模型空间、世界空间、裁剪空间容易绕晕、光照模型的计算公式记不住、以及不同渲染管线的差异Built-in和URP的Shader写法差别很大。我的建议是先把一个最简单的Unlit Shader彻底吃透搞清楚顶点着色器和片元着色器各自负责什么、SV_POSITION和POSITION的区别、appdata和v2f结构体里每个字段的含义。这些搞明白了后面加贴图、加光照、加后处理都是在这个骨架上长肉。注意本文所有Shader代码基于Unity Built-in管线编写URP管线在语法上有差异比如CGPROGRAM要换成HLSLPROGRAM内置变量名也不同但核心原理完全一致。如果你用的是URP把光照相关的内置变量替换成URP对应的即可。2. 核心思路拆解一个Shader的骨架长什么样2.1 ShaderLab的外壳与CG的内核Unity的Shader文件用的是ShaderLab语法外面一层壳负责声明属性、渲染状态、Pass组织里面用CGPROGRAM/ENDCG包裹的才是真正的Cg/HLSL代码。这个结构就像是一个快递包裹ShaderLab是纸箱和快递单CG代码是里面的东西。一个最基本的Shader结构是这样的Shader Custom/BasicShader { Properties { _MainTex (Main Texture, 2D) white {} _Color (Tint Color, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; 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 col tex2D(_MainTex, i.uv) * _Color; return col; } ENDCG } } }这段代码虽然简单但每一行都有讲究。Properties块里声明的属性是暴露给材质面板的_MainTex_ST这个变量是Unity自动生成的S代表ScaleT代表TranslateTRANSFORM_TEX宏就是拿uv乘以_ST.xy再加上_ST.zw实现贴图的缩放和偏移。2.2 顶点着色器与片元着色器的分工顶点着色器Vertex Shader对每个顶点执行一次主要负责坐标变换。片元着色器Fragment Shader对每个像素执行一次主要负责颜色计算。这个分工决定了性能优化的方向顶点数通常远少于像素数所以能在顶点着色器算的东西就不要放到片元着色器里。但也不是绝对的。比如法线变换如果你在顶点着色器里算法线插值到片元着色器后方向可能不准确尤其是模型有非均匀缩放的时候。所以法线通常是在片元着色器里重新归一化的。这就是一个典型的权衡性能 vs 精度。2.3 语义绑定GPU怎么知道你的变量是什么语义Semantic是Cg/HLSL里最容易让人困惑的东西。简单说语义就是告诉GPU“这个变量是什么”。比如POSITION表示顶点坐标SV_POSITION表示裁剪空间坐标TEXCOORD0表示第一套UVSV_Target表示输出到哪个渲染目标。这里有个常见的坑POSITION和SV_POSITION看起来像但用途完全不同。POSITION用在appdata结构体里是输入的模型空间顶点坐标SV_POSITION用在v2f结构体里是顶点着色器输出的裁剪空间坐标。如果你在v2f里写了float4 vertex : POSITION编译器不会报错但渲染结果会完全错误。3. 从零实现贴图混合与描边效果3.1 贴图混合的三种常见模式贴图混合是Shader里最基础也最常用的操作。常见的混合模式有三种正片叠底Multiply、叠加Overlay、线性插值Lerp。不同的混合模式适用于不同的场景。正片叠底就是两张贴图颜色相乘结果通常偏暗适合做阴影、污渍、暗部细节。叠加模式比较复杂底层颜色决定是正片叠底还是滤色适合做高对比度的纹理叠加。线性插值最直观用一个系数控制两张贴图的混合比例。// 正片叠底 fixed4 multiplyBlend(fixed4 base, fixed4 blend) { return base * blend; } // 线性插值混合 fixed4 lerpBlend(fixed4 base, fixed4 blend, float t) { return lerp(base, blend, t); } // 叠加模式 fixed4 overlayBlend(fixed4 base, fixed4 blend) { fixed4 result; result.rgb lerp(2 * base.rgb * blend.rgb, 1 - 2 * (1 - base.rgb) * (1 - blend.rgb), step(0.5, base.rgb)); result.a base.a; return result; }在实际项目中我经常用遮罩贴图来控制混合区域。比如角色身上的纹身用一张灰度遮罩图白色区域显示纹身黑色区域不显示灰色区域半透明过渡。这样比直接用顶点色或者硬编码区域灵活得多。3.2 描边效果的四种实现方案对比描边是卡通渲染里绕不开的需求。我试过至少四种方案各有优劣。第一种是法线外扩。在顶点着色器里把顶点沿着法线方向往外推一点用一个单独的Pass渲染背面颜色设为描边色。这种方案实现简单性能好但对硬边模型效果很差因为硬边处法线不连续外扩后会出现断裂。第二种是背面渲染加Z偏移。把背面渲染出来在屏幕空间做偏移。这种方案对硬边模型友好但描边宽度会随距离变化远处物体描边会变得很细。第三种是后处理描边。基于深度和法线纹理做边缘检测在屏幕空间画描边。这种方案描边宽度均匀但需要额外的RenderTexture性能开销较大而且无法处理被遮挡的物体。第四种是顶点色描边。在建模时把描边宽度烘焙到顶点色里顶点着色器读取顶点色来控制外扩距离。这种方案效果最好但需要美术配合而且模型修改后需要重新烘焙。// 法线外扩描边Pass Pass { Cull Front ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc float _OutlineWidth; fixed4 _OutlineColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; float3 normal normalize(v.normal); // 在观察空间外扩保证描边宽度不受模型缩放影响 float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, normal); float4 viewPos mul(UNITY_MATRIX_MV, v.vertex); viewPos.xyz viewNormal * _OutlineWidth * 0.01; o.pos mul(UNITY_MATRIX_P, viewPos); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG }这里有个细节外扩方向要在观察空间计算而不是模型空间。如果在模型空间外扩当模型有非均匀缩放时描边宽度会变得不均匀。在观察空间外扩可以保证无论模型怎么缩放描边宽度在屏幕上看起来是一致的。实操心得描边宽度不要设太大一般0.01到0.03之间就够了。太宽会显得很廉价而且容易穿模。另外描边Pass要设置Cull Front只渲染背面否则会盖住模型本身。3.3 贴图混合与描边的组合实战把贴图混合和描边组合在一起就是一个完整的卡通角色Shader了。结构上分两个Pass第一个Pass渲染描边第二个Pass渲染角色本体加贴图混合。Shader Custom/ToonCharacter { Properties { _MainTex (Base Texture, 2D) white {} _FaceTex (Face Texture, 2D) white {} _MaskTex (Blend Mask, 2D) white {} _OutlineWidth (Outline Width, Range(0, 0.05)) 0.02 _OutlineColor (Outline Color, Color) (0,0,0,1) _ShadowColor (Shadow Color, Color) (0.5,0.5,0.7,1) _ShadowThreshold (Shadow Threshold, Range(0,1)) 0.5 } SubShader { Tags { RenderTypeOpaque QueueGeometry } // Pass 1: 描边 Pass { Cull Front ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc float _OutlineWidth; fixed4 _OutlineColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); float4 viewPos mul(UNITY_MATRIX_MV, v.vertex); viewPos.xyz viewNormal * _OutlineWidth * 0.01; o.pos mul(UNITY_MATRIX_P, viewPos); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } // Pass 2: 角色本体 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _FaceTex; float4 _FaceTex_ST; sampler2D _MaskTex; float4 _MaskTex_ST; fixed4 _ShadowColor; float _ShadowThreshold; 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; }; 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; return o; } fixed4 frag (v2f i) : SV_Target { // 采样贴图 fixed4 mainCol tex2D(_MainTex, i.uv); fixed4 faceCol tex2D(_FaceTex, i.uv); fixed mask tex2D(_MaskTex, i.uv).r; // 贴图混合 fixed4 baseCol lerp(mainCol, faceCol, mask); // 简单卡通光照 float3 normal normalize(i.worldNormal); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float NdotL dot(normal, lightDir); // 阶梯化光照 float toonLight smoothstep(_ShadowThreshold - 0.05, _ShadowThreshold 0.05, NdotL); fixed3 lightColor lerp(_ShadowColor.rgb, _LightColor0.rgb, toonLight); fixed4 finalCol fixed4(baseCol.rgb * lightColor, baseCol.a); return finalCol; } ENDCG } } }这个Shader里用smoothstep做了光照的阶梯化_ShadowThreshold控制明暗交界线的位置-0.05到0.05的过渡区间让边缘稍微柔和一点避免锯齿。如果你想要更硬的边缘把过渡区间缩小到0.01甚至直接用step函数。4. 后处理从原理到可落地的实现4.1 后处理的基本流程后处理Post-Processing是在场景渲染完成后对最终图像做二次处理。Unity里做后处理的标准流程是相机挂一个脚本在OnRenderImage回调里拿到源图像用Graphics.Blit配合后处理材质做处理再输出到目标图像。using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class PostProcessBase : MonoBehaviour { public Shader postProcessShader; public Material postProcessMat; void OnEnable() { if (postProcessMat null postProcessShader ! null) { postProcessMat new Material(postProcessShader); postProcessMat.hideFlags HideFlags.HideAndDontSave; } } void OnRenderImage(RenderTexture src, RenderTexture dst) { if (postProcessMat ! null) { Graphics.Blit(src, dst, postProcessMat); } else { Graphics.Blit(src, dst); } } void OnDisable() { if (postProcessMat ! null) { DestroyImmediate(postProcessMat); } } }这个脚本是所有后处理的基础框架。OnRenderImage是Unity提供的回调src是相机渲染完的图像dst是最终输出的图像。Graphics.Blit负责把src传给材质的_MainTex然后用材质的Shader处理结果写入dst。4.2 灰度、色调映射与模糊灰度后处理是最简单的把RGB三个通道取加权平均就行。人眼对不同颜色的敏感度不同所以权重不是均等的通常用0.299、0.587、0.114这组经典权重。Shader Custom/PostProcess/Grayscale { Properties { _MainTex (Texture, 2D) white {} _Intensity (Intensity, Range(0,1)) 1.0 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float _Intensity; fixed4 frag (v2f_img i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float gray dot(col.rgb, float3(0.299, 0.587, 0.114)); col.rgb lerp(col.rgb, gray.xxx, _Intensity); return col; } ENDCG } } }v2f_img和vert_img是UnityCG.cginc里预定义的结构体和顶点着色器专门用于后处理省去了自己写顶点变换的麻烦。Cull Off ZWrite Off ZTest Always是后处理Shader的标准渲染状态因为后处理是在全屏四边形上做的不需要剔除和深度测试。高斯模糊稍微复杂一点需要做两次Pass先水平模糊再垂直模糊。这样做的好处是把O(n²)的采样次数降到O(n)。一个半径为5的高斯模糊如果一次性做二维采样需要121次拆成两次一维只需要22次。Shader Custom/PostProcess/GaussianBlur { Properties { _MainTex (Texture, 2D) white {} _BlurSize (Blur Size, Float) 1.0 } SubShader { Cull Off ZWrite Off ZTest Always // Pass 0: 水平模糊 Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag_horizontal #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; fixed4 frag_horizontal (v2f_img i) : SV_Target { float2 texelSize _MainTex_TexelSize.xy * _BlurSize; fixed4 col tex2D(_MainTex, i.uv) * 0.227027; col tex2D(_MainTex, i.uv float2(texelSize.x * 1.384615, 0)) * 0.316216; col tex2D(_MainTex, i.uv - float2(texelSize.x * 1.384615, 0)) * 0.316216; col tex2D(_MainTex, i.uv float2(texelSize.x * 3.230769, 0)) * 0.070270; col tex2D(_MainTex, i.uv - float2(texelSize.x * 3.230769, 0)) * 0.070270; return col; } ENDCG } // Pass 1: 垂直模糊 Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag_vertical #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; fixed4 frag_vertical (v2f_img i) : SV_Target { float2 texelSize _MainTex_TexelSize.xy * _BlurSize; fixed4 col tex2D(_MainTex, i.uv) * 0.227027; col tex2D(_MainTex, i.uv float2(0, texelSize.y * 1.384615)) * 0.316216; col tex2D(_MainTex, i.uv - float2(0, texelSize.y * 1.384615)) * 0.316216; col tex2D(_MainTex, i.uv float2(0, texelSize.y * 3.230769)) * 0.070270; col tex2D(_MainTex, i.uv - float2(0, texelSize.y * 3.230769)) * 0.070270; return col; } ENDCG } } }这里的权重值0.227027、0.316216、0.070270是标准的高斯核参数对应sigma约等于2.0的情况。_MainTex_TexelSize是Unity自动传入的变量xy是纹理的像素尺寸倒数zw是纹理的像素尺寸。用_MainTex_TexelSize.xy乘以_BlurSize来控制模糊半径比直接用像素偏移更灵活。C#脚本这边需要做两次Blitvoid OnRenderImage(RenderTexture src, RenderTexture dst) { if (postProcessMat ! null) { RenderTexture temp RenderTexture.GetTemporary( src.width, src.height, 0, src.format); Graphics.Blit(src, temp, postProcessMat, 0); // 水平模糊 Graphics.Blit(temp, dst, postProcessMat, 1); // 垂直模糊 RenderTexture.ReleaseTemporary(temp); } else { Graphics.Blit(src, dst); } }注意RenderTexture.GetTemporary和ReleaseTemporary必须成对使用否则会造成显存泄漏。在移动平台上这个问题尤其严重因为显存本来就紧张。4.3 后处理性能优化的几个关键点后处理是性能大户尤其是移动端。我总结了几条实战经验第一尽量合并Pass。如果多个后处理效果可以合并到一个Shader里就合并。比如灰度、对比度、饱和度这三个调整可以放在一个Pass里做完没必要分三次Blit。每次Blit都是一次全屏的纹理读写开销不小。第二降低分辨率。很多后处理效果不需要全分辨率比如Bloom可以在半分辨率甚至四分之一分辨率上做最后再上采样回来。视觉上几乎看不出差别但性能提升非常明显。第三慎用GrabPass。GrabPass会把当前屏幕内容抓取到一个纹理里开销很大。如果只是需要屏幕纹理做折射或者扭曲考虑用_CameraOpaqueTextureURP或者自己管理RenderTexture。第四移动端尽量用fixed精度。fixed是低精度浮点在移动GPU上性能比float好很多。颜色计算用fixed足够了只有坐标计算才需要float。5. 常见问题与排查技巧实录5.1 Shader编译报错的排查思路Shader报错信息往往很晦涩尤其是涉及到宏展开的时候。我的排查流程是这样的先看错误行号定位到具体哪一行。如果错误信息说“unexpected token”之类的语法错误检查上一行是否少了分号。Cg/HLSL对分号很严格少一个分号可能导致后面好几行都报错。如果错误信息涉及到未定义的变量或函数检查#include是否完整。常用的include有UnityCG.cginc基础函数和宏、Lighting.cginc光照相关、AutoLight.cginc自动光照宏。后处理Shader通常只需要UnityCG.cginc。如果错误信息说“cannot map expression to vs_4_0 instruction set”说明你的Shader指令数超过了Shader Model 4.0的限制。解决办法是精简计算或者把#pragma target提高到3.5或4.0。5.2 渲染结果不对的常见原因渲染结果不对八成是这几个原因之一坐标系搞混了。模型空间、世界空间、观察空间、裁剪空间每个空间的坐标范围都不一样。UnityObjectToClipPos是把模型空间转到裁剪空间UnityObjectToWorldNormal是把法线从模型空间转到世界空间。如果你把世界空间的坐标当模型空间用结果肯定不对。法线没有归一化。插值后的法线长度会变化在片元着色器里用之前必须normalize。我见过太多人忘了这一步导致光照忽明忽暗。纹理采样坐标不对。TRANSFORM_TEX宏需要传入uv和纹理的_ST变量如果你手动计算uv * _MainTex_ST.xy _MainTex_ST.zw效果是一样的但容易写错。另外注意_MainTex_ST的声明必须是float4 _MainTex_ST不能是fixed4。混合模式设置错误。Blend SrcAlpha OneMinusSrcAlpha是标准透明混合Blend One One是叠加混合Blend DstColor Zero是正片叠底。如果混合模式设错了透明物体要么完全不透明要么黑成一团。5.3 性能问题的定位方法性能问题用Unity Profiler和Frame Debugger来定位。Profiler看CPU和GPU的耗时分布Frame Debugger看每个Draw Call的具体内容。如果GPU耗时高先看是不是Overdraw太严重。透明物体多了Overdraw自然高能合并的Draw Call尽量合并能不用透明的就不用透明。如果某个Shader特别耗检查片元着色器里的计算量。纹理采样次数、三角函数、幂运算都是性能杀手。能预计算的预计算能查表的查表能近似就近似。如果Draw Call数量高考虑用GPU Instancing或者SRP Batcher。GPU Instancing适合大量相同材质的物体SRP Batcher适合大量不同材质但相同Shader的物体。这两个技术都能显著降低Draw Call数量。问题现象可能原因排查方法解决方案模型全黑光照计算错误或法线方向反了检查NdotL是否为负取绝对值或翻转法线描边断裂硬边处法线不连续查看模型法线改用背面渲染加Z偏移方案透明物体排序错误Queue设置不当检查Tags中的Queue调整Queue值或使用ZWrite后处理全屏泛白Blit目标格式不对检查RenderTexture格式使用src.format或ARGB32移动端掉帧精度过高或指令数超限查看Shader编译报告降精度或精简计算5.4 跨平台兼容性避坑指南不同平台对Shader的支持差异很大尤其是移动端。OpenGL ES 2.0不支持tex2DlodOpenGL ES 3.0才支持。Metal对纹理采样有额外限制比如不能在循环里做纹理采样。WebGL平台不支持#pragma geometry。我的做法是在写Shader的时候就考虑兼容性尽量用最保守的写法。如果必须用高级特性用#pragma target明确指定Shader Model版本然后在不同平台做条件编译。#if defined(SHADER_API_MOBILE) // 移动端精简版 fixed4 col tex2D(_MainTex, i.uv); #else // 桌面端完整版 fixed4 col tex2D(_MainTex, i.uv); col tex2D(_DetailTex, i.uv * 2) * 0.5; #endifSHADER_API_MOBILE是Unity预定义的宏在移动平台上会被定义。类似的还有SHADER_API_D3D11、SHADER_API_GLES3、SHADER_API_METAL等。用这些宏做条件编译可以保证同一份Shader在不同平台上都能跑。实操心得在真机上测试Shader是必须的编辑器里跑得好不代表真机上没问题。尤其是移动端GPU架构和桌面完全不同很多在桌面上没问题的写法在移动端会出各种奇怪的问题。我习惯在项目早期就在目标机型上跑一遍所有Shader把问题提前暴露出来。6. 从能跑到好用我的Shader工程化实践6.1 用Shader变体管理多平台适配#pragma multi_compile和#pragma shader_feature是管理Shader变体的两个指令。multi_compile会生成所有组合的变体shader_feature只生成材质实际用到的变体。后者的包体更小但需要确保材质在打包前已经设置了正确的关键字。#pragma multi_compile _ _USE_DETAIL #pragma multi_compile _ _USE_NORMAL_MAP #ifdef _USE_DETAIL sampler2D _DetailTex; #endif #ifdef _USE_NORMAL_MAP sampler2D _NormalMap; #endif变体数量是乘法关系。如果你有3个multi_compile每个有2个选项那就是8个变体。变体太多会导致编译时间暴涨、包体增大。所以能合并的合并能删的删。Unity的Shader Variant Collection可以手动指定需要打包的变体把不需要的剔除掉。6.2 用MaterialPropertyBlock做性能优化如果你需要给不同物体设置不同的材质属性但又不想创建多个材质实例用MaterialPropertyBlock。它可以在不破坏合批的前提下修改材质属性。MaterialPropertyBlock props new MaterialPropertyBlock(); props.SetColor(_Color, Color.red); props.SetFloat(_OutlineWidth, 0.02f); renderer.SetPropertyBlock(props);MaterialPropertyBlock的缺点是它不能修改纹理只能修改颜色和浮点数。而且它和GPU Instancing有冲突用了MaterialPropertyBlock的物体无法参与GPU Instancing合批。所以用之前要权衡一下。6.3 Shader代码的组织与复用大型项目里Shader代码的复用很重要。我的做法是把公共函数抽出来放在单独的.cginc文件里用#include引入。比如光照计算、噪声函数、坐标变换这些通用逻辑每个Shader都写一遍太蠢了。// CommonFunctions.cginc #ifndef COMMON_FUNCTIONS_INCLUDED #define COMMON_FUNCTIONS_INCLUDED float3 ApplyToonLighting(float3 normal, float3 lightDir, float3 lightColor, float3 shadowColor, float threshold) { float NdotL dot(normal, lightDir); float toonLight smoothstep(threshold - 0.05, threshold 0.05, NdotL); return lerp(shadowColor, lightColor, toonLight); } float Remap(float value, float from1, float to1, float from2, float to2) { return (value - from1) / (to1 - from1) * (to2 - from2) from2; } #endif#ifndef和#define是防止重复包含的标准做法。Unity的.cginc文件在编译时会被展开如果同一个文件被包含了多次没有保护的话会导致重复定义错误。6.4 调试Shader的实用技巧调试Shader比调试C#代码难得多因为不能打断点。我的几个常用技巧用颜色可视化中间结果。比如你想知道法线方向对不对直接把法线当颜色输出return fixed4(normal * 0.5 0.5, 1.0);。法线从-1到1映射到颜色的0到1就能直观看到法线方向。用clip函数做二分排查。如果怀疑某个计算有问题在中间加clip(value - 0.5)如果物体消失了说明value小于0.5逐步缩小范围就能定位问题。用Frame Debugger看每个Draw Call的渲染结果。Frame Debugger可以单独查看每个Pass的输出对于多Pass的Shader特别有用。你可以清楚地看到描边Pass和本体Pass各自渲染了什么。注意调试完记得把调试代码删掉。我见过有人把调试用的颜色输出忘在代码里上线后角色变成彩虹色排查了半天才发现是调试代码没删。7. 一些掏心窝子的经验写了这么多年代码Shader踩过的坑比写过的Shader还多。最大的体会是不要追求一步到位写出完美的Shader先让它跑起来再慢慢优化。我见过太多人一开始就想写一个支持所有功能、适配所有平台的万能Shader结果写到一半就烂尾了。另外数学基础真的很重要。矩阵变换、向量运算、插值这些概念如果不过关写Shader就是盲人摸象。我的建议是花点时间把线性代数的基础补一补尤其是矩阵乘法的几何意义、点积和叉积的物理含义。这些搞明白了Shader代码看起来就不再是天书了。最后说一个实际项目中的教训。有一次我们做了一个很炫的后处理效果在PC上跑得飞起结果打包到手机上直接黑屏。排查了半天发现是RenderTexture的格式在移动端不支持。后来改成RenderTextureFormat.Default才解决。从那以后我养成了一个习惯任何涉及RenderTexture的代码先在目标平台上跑一遍再往下写。这个习惯帮我省了无数个加班的夜晚。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →