Unity Shader实战:从Shader Graph回归纯代码HLSL/Cg编程
发布时间:2026/10/2 10:35:01来源:尧图网络
1. 为什么我放弃了Shader Graph回归纯代码写Shader1.1 从一次项目翻车说起去年接了一个2D横版过关项目的渲染优化需求美术同学用Shader Graph连了一套看起来很漂亮的溶解效果节点数量大概在80个左右。在编辑器里预览一切正常帧率稳定在60。但打包到移动端之后问题来了低端机上直接掉到22帧而且不同机型表现差异极大。我打开Frame Debugger一看那个Shader Graph生成的变体数量超过了200个光编译就花了不少时间运行时还有大量分支判断。这件事让我重新审视了一个问题Shader Graph确实降低了入门门槛但它不是万能的。当你需要精确控制指令数、需要针对特定硬件做优化、需要写一些Graph根本表达不出来的逻辑时纯代码写Shader仍然是不可替代的能力。这篇文章不是要否定可视化工具而是想把我这些年用Cg/HLSL写Shader的实战经验整理出来。从最基础的光照模型到贴图混合、描边、后处理每一块我都会给出可直接跑的代码并且解释清楚每一行背后的逻辑。适合已经了解Unity基本操作、想往渲染方向深入的同学也适合那些用Graph遇到瓶颈想转代码的美术向TA。1.2 纯代码Shader的核心优势在哪先把这个事情说清楚不然很多人会觉得“有Graph为什么还要手写”。第一是可控性。你写的每一行代码最终会编译成多少条指令、用了几个插值器、有没有动态分支这些都是可以精确计算的。Shader Graph生成的代码虽然也能看但中间隔了一层你很难做细粒度的干预。第二是灵活性。有些效果用节点连不出来比如基于屏幕空间导数的边缘检测、自定义的噪声函数、复杂的数学运算链。这些在HLSL里就是几行代码的事但在Graph里要么做不了要么需要写Custom Function节点——那还不如直接写代码。第三是性能优化的空间。你可以手动控制精度half vs float、手动做分支合并、手动管理寄存器数量。在移动端尤其明显一个精心手写的Shader可能比Graph生成的版本快30%到50%。第四是版本管理的友好度。代码文件是纯文本diff清晰合并冲突好处理。Graph是二进制资产多人协作时冲突几乎没法解决。当然代价是学习曲线更陡。但一旦跨过那个坎你会发现写Shader其实就是在写数学公式没那么神秘。2. Shader基础结构从零理解Cg/HLSL的骨架2.1 一个最小可用的Shader长什么样先看一个最基础的Unlit Shader它做的事情就是把一张贴图显示出来Shader Custom/BasicUnlit { Properties { _MainTex (Main Texture, 2D) white {} _TintColor (Tint Color, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _TintColor; 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) * _TintColor; return col; } ENDCG } } }这段代码虽然简单但包含了Shader的所有核心概念。我逐个拆解一下。Properties块是暴露给材质面板的参数。_MainTex后面的white是默认值表示如果没有指定贴图就用白色填充。_MainTex_ST是Unity自动生成的变量ST代表Scale和Translate用来控制贴图的平铺和偏移。SubShader里面可以有多个Pass引擎会从上到下找第一个能跑的。Tags里的RenderType和Queue决定了渲染顺序和分类这个在后面讲描边和透明混合时会重点说。appdata是顶点着色器的输入结构语义POSITION表示顶点坐标TEXCOORD0表示第一套UV。v2f是顶点到片元的传递结构SV_POSITION是必须的表示裁剪空间坐标。顶点着色器里UnityObjectToClipPos把模型空间坐标转到裁剪空间这一步是必须的。TRANSFORM_TEX宏展开后是uv * _MainTex_ST.xy _MainTex_ST.zw处理贴图的平铺偏移。片元着色器里tex2D采样贴图乘以颜色返回。SV_Target表示输出到渲染目标。注意fixed4在移动端是half4在桌面端是float4。如果你确定某个变量不需要高精度用fixed或half可以省寄存器。但位置坐标和深度相关的一定要用float。2.2 顶点着色器和片元着色器的职责划分很多人写Shader时搞不清楚哪些计算该放顶点、哪些该放片元。我的原则是能放顶点就算顶点但涉及逐像素变化的一定放片元。顶点着色器每个顶点执行一次片元着色器每个像素执行一次。一个模型可能只有几百个顶点但渲染出来可能有几十万像素。所以把计算放在顶点着色器里性能收益是数量级的。但有些计算不能放顶点比如法线插值后的归一化。因为顶点法线插值到片元后长度会变化必须在片元里重新归一化。还有视线方向、光照方向这些如果放在顶点里算对于大三角形会出现明显的马赫带效应。我的一般做法是顶点里做坐标变换、UV变换、传递必要的世界空间位置和法线片元里做光照计算、贴图采样、所有与视角相关的运算。2.3 语义绑定与插值器数量限制每个v2f结构里的字段都占用一个插值器。移动端一般只有8个插值器可用桌面端是32个。如果你传递了太多数据编译时会报错。常见的插值器分配是这样的语义用途是否必须SV_POSITION裁剪空间坐标必须TEXCOORD0主UV通常必须TEXCOORD1副UV/光照贴图UV按需TEXCOORD2世界空间法线按需TEXCOORD3世界空间位置按需TEXCOORD4视线方向按需COLOR顶点颜色按需如果插值器不够用可以把一些低精度的数据打包。比如把两个half精度的值打包进一个float里用的时候再解包。这个技巧在移动端Shader里很常见。3. 光照模型实战从Lambert到Blinn-Phong3.1 漫反射为什么是N dot L漫反射的核心公式就一行float3 N normalize(i.worldNormal); float3 L normalize(_WorldSpaceLightPos0.xyz); float NdotL saturate(dot(N, L)); float3 diffuse _LightColor0.rgb * NdotL * _DiffuseColor.rgb;dot(N, L)的几何意义是法线和光线方向的夹角余弦值。当光线垂直照射表面时N和L平行dot结果为1最亮当光线与表面平行时dot结果为0最暗。saturate把结果限制在0到1之间避免背面出现负值。这里有个细节_WorldSpaceLightPos0在平行光下表示方向在点光源下表示位置。如果你要支持多种光源类型需要判断_WorldSpaceLightPos0.w的值。w为0是平行光w为1是点光源。实操心得很多教程直接写dot(N, L)不加saturate在平行光下没问题但点光源在物体背面时会出现负值导致颜色异常。养成加saturate的习惯。3.2 高光反射Phong和Blinn-Phong的差异Phong模型的高光计算是反射向量和视线方向的点积float3 R reflect(-L, N); float spec pow(saturate(dot(R, V)), _Shininess);Blinn-Phong则是用半角向量float3 H normalize(L V); float spec pow(saturate(dot(N, H)), _Shininess);两者的视觉差异在于Phong的高光在掠射角下会变得很大很散Blinn-Phong则更集中更自然。性能上Blinn-Phong少了一次reflect运算而且半角向量可以放在顶点着色器里算虽然精度会差一点。我实测下来Blinn-Phong在大多数场景下视觉效果更好而且性能更优。除非你有特殊需求否则直接用Blinn-Phong就行。_Shininess参数控制高光的集中度。值越大高光越小越亮值越小高光越大越散。一般金属材质用50到200塑料材质用20到80。3.3 完整的光照Shader代码把漫反射和高光合在一起再加上环境光就是一个完整的光照ShaderShader Custom/BlinnPhongLighting { Properties { _MainTex (Albedo, 2D) white {} _DiffuseColor (Diffuse Color, Color) (1,1,1,1) _SpecularColor (Specular Color, Color) (1,1,1,1) _Shininess (Shininess, Range(1, 256)) 64 _AmbientStrength (Ambient Strength, Range(0, 1)) 0.2 } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc 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; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _DiffuseColor; fixed4 _SpecularColor; float _Shininess; float _AmbientStrength; 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 { float3 N normalize(i.worldNormal); float3 L normalize(_WorldSpaceLightPos0.xyz); float3 V normalize(_WorldSpaceCameraPos - i.worldPos); float3 H normalize(L V); fixed3 albedo tex2D(_MainTex, i.uv).rgb * _DiffuseColor.rgb; float NdotL saturate(dot(N, L)); float3 diffuse _LightColor0.rgb * albedo * NdotL; float NdotH saturate(dot(N, H)); float3 specular _LightColor0.rgb * _SpecularColor.rgb * pow(NdotH, _Shininess); float3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * albedo * _AmbientStrength; float3 finalColor diffuse specular ambient; return fixed4(finalColor, 1.0); } ENDCG } } }这个Shader可以直接用在场景里配合平行光就能看到效果。#pragma multi_compile_fwdbase是为了支持光照贴图和阴影虽然这个版本还没处理阴影但加上这个编译指令不会出错。4. 贴图混合从简单叠加到高级混合模式4.1 基础贴图混合的数学原理贴图混合本质上就是对两个颜色值做插值。最简单的线性插值float4 colorA tex2D(_TexA, uv); float4 colorB tex2D(_TexB, uv); float4 finalColor lerp(colorA, colorB, _BlendFactor);lerp(a, b, t)的公式是a * (1 - t) b * t。当t为0时返回at为1时返回bt为0.5时返回两者的平均值。但实际项目中我们经常需要更复杂的混合模式。比如Photoshop里的正片叠底、滤色、叠加等。这些在Shader里都可以用数学公式实现混合模式公式HLSL实现正片叠底a * bcolorA * colorB滤色1 - (1-a)*(1-b)1 - (1-colorA) * (1-colorB)叠加根据b的值决定需要分支或step柔光复杂公式用pow和sqrt近似正片叠底会让结果变暗适合做阴影和暗部细节。滤色会让结果变亮适合做光效和亮部叠加。4.2 双贴图混合的完整实现下面这个Shader实现了两张贴图的混合支持四种混合模式切换Shader Custom/TextureBlend { Properties { _TexA (Texture A, 2D) white {} _TexB (Texture B, 2D) white {} _BlendFactor (Blend Factor, Range(0, 1)) 0.5 [KeywordEnum(Normal, Multiply, Screen, Overlay)] _BlendMode (Blend Mode, Float) 0 } SubShader { Tags { RenderTypeOpaque } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _BLENDMODE_NORMAL _BLENDMODE_MULTIPLY _BLENDMODE_SCREEN _BLENDMODE_OVERLAY #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _TexA; sampler2D _TexB; float4 _TexA_ST; float4 _TexB_ST; float _BlendFactor; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float2 uvA TRANSFORM_TEX(i.uv, _TexA); float2 uvB TRANSFORM_TEX(i.uv, _TexB); fixed4 colorA tex2D(_TexA, uvA); fixed4 colorB tex2D(_TexB, uvB); fixed3 blended; #if defined(_BLENDMODE_NORMAL) blended lerp(colorA.rgb, colorB.rgb, _BlendFactor); #elif defined(_BLENDMODE_MULTIPLY) blended lerp(colorA.rgb, colorA.rgb * colorB.rgb, _BlendFactor); #elif defined(_BLENDMODE_SCREEN) blended lerp(colorA.rgb, 1 - (1 - colorA.rgb) * (1 - colorB.rgb), _BlendFactor); #elif defined(_BLENDMODE_OVERLAY) fixed3 overlay lerp(2 * colorA.rgb * colorB.rgb, 1 - 2 * (1 - colorA.rgb) * (1 - colorB.rgb), step(0.5, colorA.rgb)); blended lerp(colorA.rgb, overlay, _BlendFactor); #endif return fixed4(blended, 1.0); } ENDCG } } }#pragma multi_compile会生成多个变体在材质面板上切换时Unity会自动选择对应的变体。step(0.5, colorA.rgb)是一个逐分量的判断当colorA大于0.5时返回1否则返回0。注意multi_compile会增加变体数量每个关键字组合都会生成一个变体。如果关键字太多编译时间和包体都会膨胀。建议用shader_feature代替它只编译材质实际用到的变体。4.3 三平面映射解决地形贴图拉伸地形渲染时如果直接用UV映射陡峭的地方贴图会被拉伸得很厉害。三平面映射Triplanar Mapping可以解决这个问题float3 worldPos i.worldPos; float3 worldNormal abs(normalize(i.worldNormal)); float4 colorX tex2D(_MainTex, worldPos.yz * _Scale); float4 colorY tex2D(_MainTex, worldPos.xz * _Scale); float4 colorZ tex2D(_MainTex, worldPos.xy * _Scale); float3 blend worldNormal / (worldNormal.x worldNormal.y worldNormal.z); float4 finalColor colorX * blend.x colorY * blend.y colorZ * blend.z;原理是在三个平面上分别采样然后根据法线的分量做加权平均。法线朝上的地方用XZ平面朝前的地方用XY平面朝右的地方用YZ平面。这样无论地形多陡贴图都不会拉伸。代价是采样次数变成了三次性能开销是普通贴图的三倍。所以一般只在地形这种大面积的物体上用小物件没必要。5. 描边效果五种方案的取舍与实现5.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; float4 clipPos UnityObjectToClipPos(v.vertex); float3 clipNormal mul((float3x3)UNITY_MATRIX_MVP, v.normal); float2 offset normalize(clipNormal.xy) * _OutlineWidth * clipPos.w; clipPos.xy offset; o.pos clipPos; return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG }关键点在于clipPos.w的乘法。因为裁剪空间是透视除法后的结果远处的物体会变小如果不乘w描边宽度会随距离变化。乘上w之后描边在屏幕上保持恒定宽度。Cull Front表示只渲染背面这样描边只会出现在模型轮廓外面。ZWrite On保证描边不会穿透前面的物体。实操心得这个方案对硬边模型效果很好但对软边或法线不连续的模型会出现描边断裂。解决办法是烘焙一套平滑法线到顶点色或第二套UV里用那套法线做膨胀。5.2 屏幕空间边缘检测适合后处理管线如果项目已经用了后处理可以在后处理阶段做边缘检测。原理是用Sobel算子对深度图或法线图做卷积float3 sobelX float3(-1, 0, 1); float3 sobelY float3(-1, 0, 1); float2 texelSize _MainTex_TexelSize.xy; float depthCenter SampleDepth(uv); float depthLeft SampleDepth(uv - float2(texelSize.x, 0)); float depthRight SampleDepth(uv float2(texelSize.x, 0)); float depthUp SampleDepth(uv float2(0, texelSize.y)); float depthDown SampleDepth(uv - float2(0, texelSize.y)); float edgeX abs(depthLeft - depthRight); float edgeY abs(depthUp - depthDown); float edge sqrt(edgeX * edgeX edgeY * edgeY); float outline step(_Threshold, edge);这个方案的优点是不需要额外的Pass描边宽度均匀而且可以做出很多变体比如只描外轮廓、只描内边缘。缺点是需要深度图或法线图对透明物体支持不好。5.3 五种描边方案对比方案原理优点缺点适用场景背面膨胀背面沿法线外扩实现简单性能好硬边断裂宽度不均卡通渲染硬边模型屏幕空间深度/法线边缘检测宽度均匀可后处理需要额外RT透明物体问题后处理管线全屏描边顶点色膨胀预烘焙膨胀方向可控性最强需要美术配合高品质角色几何着色器在GS里生成描边面片精确控制移动端不支持PC/主机法线贴图用法线图做边缘检测不需要额外Pass需要法线图已有法线图的模型我一般推荐背面膨胀法作为默认方案因为它不依赖任何额外资源性能也够用。如果项目有后处理管线屏幕空间方案是更好的选择。6. 后处理从OnRenderImage到全屏Shader6.1 后处理的基本流程Unity的后处理有两种做法一种是传统的OnRenderImage回调一种是URP/HDRP的Render Feature。这里先讲传统做法因为它更通用理解了之后再迁移到URP也不难。基本流程是相机渲染完场景后把结果渲染到一张RT上然后调用OnRenderImage在回调里用我们的Shader处理这张RT再输出到屏幕。[ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class PostProcessBase : MonoBehaviour { public Material material; void OnRenderImage(RenderTexture src, RenderTexture dst) { if (material ! null) { Graphics.Blit(src, dst, material); } else { Graphics.Blit(src, dst); } } }Graphics.Blit会把src传给Shader的_MainTex然后渲染一个全屏Quad到dst。6.2 一个完整的后处理Shader模板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 #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float _Intensity; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f 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 } } }Cull Off ZWrite Off ZTest Always是后处理Shader的标准配置因为全屏Quad不需要剔除和深度测试。灰度公式0.299 * R 0.587 * G 0.114 * B是ITU-R BT.601标准符合人眼对不同颜色的敏感度。绿色权重最高蓝色最低。6.3 高斯模糊后处理中的经典算法高斯模糊是后处理里最常用的效果之一用于Bloom、景深、毛玻璃等。核心思想是用高斯核做卷积float4 fragBlur (v2f i) : SV_Target { float2 texelSize _MainTex_TexelSize.xy; float4 color 0; float totalWeight 0; for (int x -_BlurRadius; x _BlurRadius; x) { for (int y -_BlurRadius; y _BlurRadius; y) { float2 offset float2(x, y) * texelSize; float weight exp(-(x * x y * y) / (2 * _Sigma * _Sigma)); color tex2D(_MainTex, i.uv offset) * weight; totalWeight weight; } } return color / totalWeight; }但这样写是O(n²)的复杂度半径10就是441次采样性能不可接受。实际项目中用的是分离式高斯模糊先水平方向模糊再垂直方向模糊。这样复杂度降到O(n)半径10只需要21次采样。// 水平Pass float4 fragBlurH (v2f i) : SV_Target { float2 texelSize _MainTex_TexelSize.xy; float4 color 0; float totalWeight 0; for (int x -_BlurRadius; x _BlurRadius; x) { float weight exp(-(x * x) / (2 * _Sigma * _Sigma)); color tex2D(_MainTex, i.uv float2(x * texelSize.x, 0)) * weight; totalWeight weight; } return color / totalWeight; }垂直Pass同理只是把偏移方向改成y。两次Pass之后就是完整的高斯模糊。实操心得高斯模糊的Sigma和半径要匹配。经验公式是radius ceil(3 * sigma)这样能覆盖99.7%的高斯分布。如果半径太小模糊会显得有断层半径太大则浪费性能。7. 常见问题与排查技巧实录7.1 Shader不生效的排查清单写Shader最让人抓狂的就是改了代码但场景里没变化。我整理了一个排查顺序现象可能原因排查方法材质变粉红Shader编译错误打开Console看报错检查语法改了代码没变化变体没重新编译右键Shader Reimport或重启Unity效果和预期不符语义绑定错误用Frame Debugger看每个Pass的输出移动端和PC不一致精度问题把half改成float试试透明物体渲染异常Queue设置错误检查Tags里的Queue值描边穿透ZWrite/ZTest配置调整ZWrite和ZTest状态粉红材质是最常见的99%是编译错误。Unity的Shader编译器报错信息有时候不太直观我的经验是看第一行报错就够了后面的往往是连锁反应。7.2 性能优化的几个实用技巧第一减少插值器数量。移动端只有8个插值器超了直接编译失败。把不需要的字段去掉或者把两个half打包进一个float。第二慎用动态分支。if语句在Shader里如果条件不是uniform的会生成两个分支都执行再选择结果性能反而更差。能用step和lerp代替的就不要用if。第三控制贴图采样次数。每次tex2D都是一次内存访问移动端尤其昂贵。能合并的贴图就合并到一张图集里用不同的UV区域采样。第四精度选择。颜色用fixedUV用half位置和深度用float。不要所有变量都用float那样寄存器压力很大。第五变体管理。用shader_feature代替multi_compile只编译实际用到的变体。在Player Settings里可以设置变体剥离把没用的变体去掉。7.3 一个真实的性能优化案例之前有个项目角色Shader在低端机上只有25帧。我打开Frame Debugger分析发现主要问题是插值器用了12个超过了移动端的8个限制导致部分数据被强制降精度片元着色器里有3个动态分支采样了5张贴图优化方案把世界空间位置和视线方向合并计算减少2个插值器用step和lerp替换所有动态分支把3张贴图合并到一张图集采样次数从5降到3优化后帧率从25提升到52效果几乎看不出差异。这个案例说明Shader优化不需要什么黑科技把基础的事情做好就能有显著收益。8. 从代码Shader到URP的迁移思路8.1 URP Shader的基本结构变化URP的Shader结构和内置管线有较大差异。最大的变化是光照计算从Lighting.cginc变成了Lighting.hlsl而且需要引入URP的ShaderLibraryShader Custom/URP/SimpleLit { Properties { _BaseColor (Base Color, Color) (1,1,1,1) _BaseMap (Base Map, 2D) white {} } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Tags { LightModeUniversalForward } 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 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; }; TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; CBUFFER_END Varyings vert (Attributes input) { Varyings output; VertexPositionInputs posInputs GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normInputs GetVertexNormalInputs(input.normalOS); output.positionCS posInputs.positionCS; output.positionWS posInputs.positionWS; output.normalWS normInputs.normalWS; output.uv TRANSFORM_TEX(input.uv, _BaseMap); return output; } half4 frag (Varyings input) : SV_Target { half4 baseMap SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); half3 albedo baseMap.rgb * _BaseColor.rgb; Light mainLight GetMainLight(); half NdotL saturate(dot(normalize(input.normalWS), mainLight.direction)); half3 color albedo * mainLight.color * NdotL; return half4(color, 1.0); } ENDHLSL } } }几个关键变化CGPROGRAM变成HLSLPROGRAMsampler2D变成TEXTURE2D和SAMPLER分离tex2D变成SAMPLE_TEXTURE2D材质属性要放在CBUFFER里以支持SRP Batcher。8.2 SRP Batcher的兼容性要求SRP Batcher是URP的重要优化它要求所有材质属性都声明在CBUFFER_START(UnityPerMaterial)和CBUFFER_END之间。如果漏了某个属性SRP Batcher就会失效DrawCall会显著增加。我踩过的坑是在Properties里声明了属性但在CBUFFER里忘了写结果SRP Batcher一直不生效排查了很久才发现。所以写URP Shader时Properties和CBUFFER要一一对应。8.3 后处理在URP中的实现方式URP的后处理需要用ScriptableRendererFeature比内置管线复杂一些。基本步骤是创建一个继承ScriptableRendererFeature的类在里面定义一个继承ScriptableRenderPass的内部类在Pass的Execute方法里做Blit操作把Feature添加到URP Asset的Renderer列表里public class GrayscaleFeature : ScriptableRendererFeature { class GrayscalePass : ScriptableRenderPass { public Material material; public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd CommandBufferPool.Get(Grayscale); RenderTargetIdentifier source renderingData.cameraData.renderer.cameraColorTarget; int tempRT Shader.PropertyToID(_TempRT); cmd.GetTemporaryRT(tempRT, renderingData.cameraData.cameraTargetDescriptor); cmd.Blit(source, tempRT, material); cmd.Blit(tempRT, source); cmd.ReleaseTemporaryRT(tempRT); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } GrayscalePass pass; public override void Create() { pass new GrayscalePass(); pass.renderPassEvent RenderPassEvent.AfterRenderingPostProcessing; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { renderer.EnqueuePass(pass); } }这个模板可以直接用把material换成你的后处理材质就行。9. 一些零散但重要的经验9.1 关于Shader调试Unity没有Shader断点调试但有几个实用手段Frame Debugger看每个DrawCall的输入输出确认Shader是否按预期执行RenderDoc更强大的抓帧工具可以看每个像素的着色器执行过程颜色调试法把中间结果直接输出成颜色比如把法线可视化、把UV可视化Visual Studio的Shader调试需要安装Graphics Tools可以单步调试HLSL我最常用的是颜色调试法简单直接。比如怀疑法线有问题就return float4(normal * 0.5 0.5, 1)法线可视化后一眼就能看出问题。9.2 关于代码组织Shader代码也是代码需要良好的组织。我的习惯是公共函数抽到单独的cginc文件里用#define定义常量不要硬编码魔法数字变量命名要有意义_MainTex比_Tex1好_OutlineWidth比_Width好加注释尤其是数学公式的推导过程9.3 关于学习路径如果你刚开始学Shader我的建议是先照着教程写一遍基础光照Shader理解每个变量的含义然后尝试修改参数观察效果变化接着自己实现一些简单效果比如颜色渐变、UV动画再进阶到多Pass、后处理、URP最后研究源码看Unity内置Shader是怎么写的不要一上来就啃BRDF和PBR那些是进阶内容。先把基础的光照模型和贴图采样搞透后面自然水到渠成。我在实际项目里用纯代码写Shader这些年最大的体会是Shader不是玄学它就是数学。每一个效果背后都有明确的数学公式理解了公式就理解了一切。那些看起来酷炫的效果拆开来看无非是几个向量运算和插值。所以不要怕打开编辑器从最简单的开始写写着写着就通了。
网站建设高端定制企业官网