资讯动态

Unity Shader从会改到会写:Blinn-Phong、URP与面试实战

发布时间:2026/10/26 4:30:29 来源:尧图企业网站定制
做 Unity 项目时间久了很多人都会遇到一个很尴尬的阶段材质面板上的参数能调Shader 文件能打开、能改几个数字、能加一行贴图采样但只要面试官说一句“那你从零写一个带高光的 Shader 吧”手就停在键盘上了。这种状态在面试里非常吃亏因为面试官判断的不是你见过多少 Shader而是你脑子里有没有一条从顶点数据到屏幕像素的完整链路。本文围绕“会改 Shader 但不会写 Shader”这个典型断层展开把 Unity Shader 从语法结构、坐标空间、光照模型到内置管线与 URP 两套写法完整串一遍并给出可直接复制运行的完整 Shader 代码、面试高频问法以及线上排查清单。无论你是准备 Unity 客户端面试的应届生还是做了两三年业务、只会拖材质的开发者都可以跟着本文把“改”升级成“写”。1. 会改 Shader 和会写 Shader差距到底在哪1.1 “会改”通常只覆盖了最表层大多数人说“我会改 Shader”实际动作往往是这几类把_Color的默认值改掉、把_Gloss的范围从 32 调到 128、在别人的 Shader 里多加一张遮罩贴图、把Cull Back改成Cull Off。这些操作确实能解决一部分美术需求但它们有一个共同点——不需要理解数据是怎么流动的。一旦需求变成“角色受击时闪一下白”“地面要有流动的扫描线”“模型边缘要有一圈描边”你会发现找不到能直接改的地方因为这些效果不是靠调参数实现的而是要在顶点着色器或片元着色器里增加新的计算逻辑。面试官非常清楚这条分界线所以他不会问你_MainTex是什么意思他会问你“顶点着色器的输出为什么要做插值”。1.2 “会写”要求掌握的四层知识真正能独立写 Shader需要同时具备四层能力缺一层都会在某类需求前卡住第一层是 ShaderLab 语法层。你要知道一个.shader文件里Properties、SubShader、Pass、FallBack各自负责什么Tags里的RenderType和Queue为什么会影响渲染顺序LightMode又为什么决定了这个 Pass 参不参与光照计算。第二层是 GPU 数据流层。顶点数据从哪里来、经过哪些矩阵变换、为什么片元着色器的输入是插值后的结果、SV_POSITION到底是谁写入的。这一层不理解写出来的 Shader 会出现“模型扭曲”“法线方向反了”“贴图跟着相机跑”这类诡异问题而且很难靠试错修好。第三层是数学与光照模型层。Lambert、Phong、Blinn-Phong 的区别在哪半程向量为什么比反射向量省一次计算法线贴图为什么要做切线空间变换。面试里让你手写一个 Blinn-Phong考的就是这一层。第四层是工程层。Shader 变体为什么会爆炸、CBUFFER和 SRP Batcher 有什么关系、移动端为什么要注意half精度、if分支在 GPU 上到底贵不贵。这一层决定你写出来的东西能不能上线而不仅仅是能不能跑。1.3 本文的读者定位与阅读方式本文假设你已经能在 Unity 里创建材质、挂贴图、搭一个简单的场景但不要求你写过完整的 Shader。文章会先用两节把概念和环境说清楚然后用一个完整的 Blinn-Phong 案例带你从零写第一版接着把同一套数学迁移到 URP最后用面试问答和排查表收尾。建议的阅读方式是第一遍通读建立全局印象第二遍打开 Unity 跟着敲代码。Shader 是一门必须动手的技能只看不写看到第三遍还是不会。2. 环境准备与版本说明2.1 渲染管线的选择会直接改变写法在动手之前必须先确认一件事你的项目用的是内置渲染管线Built-in Render Pipeline还是 URP/HDRP。这是新手最容易忽略、也最容易踩坑的地方因为这两套管线的 Shader 写法几乎不通用。内置管线的 Shader 使用CGPROGRAM块通过UnityCG.cginc、Lighting.cginc这些内置文件获取光照和变换函数。URP 使用HLSLPROGRAM块通过Packages/com.unity.render-pipelines.universal/ShaderLibrary/下的 HLSL 文件获取能力光照信息的获取方式也变成了GetMainLight()这类显式调用。本文会给出两套完整代码你可以根据自己的项目选择。如果只是学习建议先学内置管线版本因为它更贴近“手动算光照”的原始形态理解之后再迁移到 URP 会轻松很多。2.2 编辑器、版本与辅助工具本文示例以 Unity 2021 LTS 与 URP 12.x 为参考环境编写重点演示的是配置思路和数学原理内置函数的签名在不同版本之间可能存在细微差异请以你实际工程的版本文档为准。如果你的项目用的是更早或更新的版本代码主体不变个别函数名需要按官方文档核对。编辑器方面Visual Studio 或 Rider 都可以关键在于要给 Unity 关联正确的编辑器否则 Shader 文件没有语法高亮和跳转。在 Unity 的 Preferences → External Tools 里设置外部脚本编辑器即可。调试工具推荐三个Frame Debugger 用来看每一个 Draw Call 对应的 Pass 和渲染状态RenderDoc 用来看更底层的管线状态和纹理内容Scene 视图的 Shaded Wireframe 模式配合法线可视化能快速判断法线方向是否正确。2.3 示例工程目录结构为了让后文的文件路径保持一致这里先约定一个简单的目录结构Assets/ ├── Shaders/ │ ├── Builtin/ │ │ └── BlinnPhongSimple.shader │ └── URP/ │ └── URPBlinnPhongSimple.shader ├── Materials/ │ ├── BuiltinBlinnPhong.mat │ └── URPBlinnPhong.mat ├── Textures/ │ └── Grid_Albedo.png └── Scenes/ └── ShaderDemo.unity把 Shader 按管线分目录存放是一个很小的习惯但在真实项目里能省掉大量“这个 Shader 到底能不能在 URP 下用”的沟通成本。3. ShaderLab 结构拆解一个 .shader 文件由什么组成3.1 Shader 与 Material 的关系先用一句话把关系说清楚Shader 是“怎么做”的说明书Material 是“用什么参数做”的配置单。一个 Shader 可以对应无数个 Material每个 Material 保存一组Properties里声明的参数值运行时 Unity 把这些值传给 GPU。这个关系解释了一个常见困惑为什么改了 Shader 里的Properties默认值场景里已经存在的材质没有变化因为材质里存的是它自己的一份序列化值Shader 里的默认值只在这个材质第一次创建、或者手动点 Reset 的时候才会生效。3.2 四个核心块的职责划分一个标准的 Shader 文件包含四个部分每一部分的作用边界非常清晰Shader Custom/DemoShader { Properties { _MainTex (主贴图, 2D) white {} _Color (基础颜色, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque QueueGeometry } LOD 200 Pass { Tags { LightModeForwardBase } // 渲染状态与着色器代码 } } FallBack Diffuse }Properties块声明的是暴露给材质面板的参数语法是“变量名 (显示名, 类型) 默认值”。注意它只是声明真正在代码里使用还需要在 CGPROGRAM/HLSLPROGRAM 块里再声明一次同名变量。SubShader块是给不同硬件能力准备的备用方案Unity 会从上到下选择第一个当前显卡支持的 SubShader。桌面端项目通常只写一个但如果你要兼容老旧设备可以写多个不同 LOD 的 SubShader。Pass块是一次完整的绘制。一个 SubShader 里可以有多个 Pass它们按顺序执行这就是描边、多光源叠加等效果的实现基础。每个 Pass 内部的Tags { LightMode... }决定了这个 Pass 在什么时机被调用。FallBack是兜底方案当所有 SubShader 都不支持时使用指定的内置 Shader。写上它主要是为了在低端设备上至少能显示点东西而不是直接变成粉红色。3.3 渲染状态指令为什么不能随便改Pass 内部除了着色器代码还有一组渲染状态指令它们不参与计算但直接决定 GPU 怎么处理这些像素Cull Back // 剔除背面默认值 ZWrite On // 写入深度缓冲 ZTest LEqual // 深度测试规则 Blend SrcAlpha OneMinusSrcAlpha // 透明混合Cull Back是最常被误改的一项。很多人为了让半透明模型正反面都显示直接把Cull改成Off结果是三角形数量翻倍、深度排序更混乱。正确的做法是先想清楚这个效果真的需要看到背面吗描边效果之所以用Cull Front是因为它要让外扩后的背面可见而正面被原模型挡住这是有明确目的的使用。ZWrite和Blend的组合是透明物体的关键。半透明物体必须关掉深度写入ZWrite Off并开启混合否则会出现“后面的透明物体被前面的完全挡住”的现象。这个问题在面试里经常被问到因为它能区分出你是真的理解渲染顺序还是只会复制粘贴。3.4 顶点着色器与片元着色器的分工这两个函数是 Shader 的核心分工可以用一句话概括顶点着色器负责“把点放到正确的位置”片元着色器负责“给每个像素上正确的颜色”。顶点着色器的执行次数等于模型的顶点数它接收顶点数据位置、法线、UV 等输出经过变换的数据。片元着色器对每个被光栅化覆盖的像素执行一次接收的是顶点输出经过插值后的结果。这里有一个必须理解的点片元着色器拿到的世界坐标、法线、UV都不是某个顶点原封不动的值而是三角形三个顶点插值出来的结果。这解释了两个现象——为什么低模上做逐像素光照才会有平滑过渡以及为什么法线必须在顶点着色器里转成世界空间后再插值而不能在片元着色器里用顶点位置去反推。4. 坐标空间与语义Shader 里最容易出错的部分4.1 从模型空间到屏幕的完整链路一个顶点从模型文件里的坐标到最终出现在屏幕上要经历四次空间变换模型空间Object Space→ 世界空间World Space通过模型矩阵unity_ObjectToWorld完成把顶点放到场景中的实际位置。世界空间 → 观察空间View Space通过视图矩阵完成把世界坐标转换到以摄像机为原点的坐标系。这一步决定了“相机看到什么”。观察空间 → 裁剪空间Clip Space通过投影矩阵完成把视锥体外的顶点裁掉同时为透视除法做准备。内置函数UnityObjectToClipPos一次性完成了上面三步所以你在顶点着色器里最常看到的就是它。裁剪空间 → 屏幕空间由 GPU 自动完成包括透视除法和视口映射。你在片元着色器里拿到的SV_POSITION就是已经完成这一步的屏幕坐标。理解这条链路的实际价值在于当你要做一个跟随相机的效果比如广告牌 Billboard你需要的是视图矩阵的逆当你要做一个基于世界坐标的溶解效果你需要的是世界坐标当你要做屏幕空间的后处理你需要的是裁剪坐标。搞清楚自己在哪个空间比背函数名重要得多。4.2 法线为什么不能直接用模型矩阵变换这是一个高频面试题也是很多人写第一个光照 Shader 时踩的坑。原因是模型矩阵里可能包含非等比缩放。假设一个物体在 X 轴被拉长了两倍那么原本垂直于表面的法线如果也乘同一个矩阵就不再垂直于变形后的表面了。正确做法是使用模型矩阵的逆转置矩阵。Unity 已经把这个步骤封装好了内置管线用UnityObjectToWorldNormal(v.normal)URP 用GetVertexNormalInputs(IN.normalOS).normalWS。你不需要自己算逆转置但必须知道为什么不能直接乘。还有一个相关细节法线归一化应该在片元着色器里再做一次。因为顶点法线经过插值后长度通常不再是 1直接用会导致光照强度偏暗或者高光位置偏移。4.3 语义Semantic到底是什么语义是 CPU 和 GPU 之间、顶点着色器和片元着色器之间的“约定标记”。它告诉 GPU这个变量里装的是位置、法线还是纹理坐标。常见的输入语义有POSITION模型空间顶点位置、NORMAL模型空间法线、TANGENT切线、TEXCOORD0到TEXCOORD7纹理坐标也可以复用来传自定义数据、COLOR顶点色。常见的输出语义有SV_POSITION裁剪空间位置必须由顶点着色器输出、SV_TARGET颜色输出目标片元着色器的返回值、SV_Depth手动写入深度用于特殊效果。初学者最容易犯的错误是在v2f结构体里用TEXCOORD0传了世界坐标然后又在片元着色器里按 UV 去采样贴图。语义本身不限制你装什么数据它只是编号所以编译器不会报错但结果一定是错的。养成“变量名带空间后缀”的习惯比如worldNormal、worldPos、uvMain能大幅降低这类错误的发生率。5. 完整实战手写一个 Blinn-Phong 光照 Shader5.1 先把光照公式想清楚在写代码之前先在纸上把公式列出来这一步很多人跳过结果就是对着别人的代码抄抄完也不知道每一行在算什么。环境光部分ambient UNITY_LIGHTMODEL_AMBIENT.rgb × albedo。它模拟的是场景中多次反弹的间接光成本极低但能让背光面不至于全黑。漫反射部分diffuse albedo × lightColor × max(0, dot(N, L))。N是归一化后的世界空间法线L是指向光源的归一化方向点积结果表示表面朝向光源的程度。高光部分Blinn-Phong 用半程向量H normalize(L V)其中V是指向摄像机的方向然后用pow(max(0, dot(N, H)), gloss)控制高光的锐利程度。相比原始 Phong 用反射向量半程向量少算一次反射而且在掠射角下表现更稳定所以现代引擎基本都用 Blinn-Phong 变体。最终颜色就是三者相加。理解了这个公式切换到任何管线都只是换 API 的问题。5.2 完整 Shader 代码下面这份代码可以直接保存为Assets/Shaders/Builtin/BlinnPhongSimple.shader在内置管线项目里创建一个材质并指定它即可使用。// 文件路径Assets/Shaders/Builtin/BlinnPhongSimple.shader Shader Custom/Builtin/BlinnPhongSimple { Properties { _MainTex (主贴图, 2D) white {} _Color (基础颜色, Color) (1,1,1,1) _SpecularColor (高光颜色, Color) (1,1,1,1) _Gloss (光泽度, Range(1, 128)) 32 } SubShader { Tags { RenderTypeOpaque QueueGeometry } LOD 200 Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; fixed4 _SpecularColor; float _Gloss; 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 { float3 N normalize(i.worldNormal); float3 L normalize(UnityWorldSpaceLightDir(i.worldPos)); float3 V normalize(UnityWorldSpaceViewDir(i.worldPos)); float3 H normalize(L V); fixed4 albedo tex2D(_MainTex, i.uv) * _Color; float NdotL saturate(dot(N, L)); float3 diffuse albedo.rgb * _LightColor0.rgb * NdotL; float NdotH saturate(dot(N, H)); float3 specular _SpecularColor.rgb * _LightColor0.rgb * pow(NdotH, _Gloss); float3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * albedo.rgb; return fixed4(diffuse specular ambient, albedo.a); } ENDCG } } FallBack Diffuse }5.3 逐段解释关键代码TRANSFORM_TEX(v.uv, _MainTex)这一行做了两件事先乘_MainTex_ST.xy实现平铺Tiling再加上_MainTex_ST.zw实现偏移Offset。_ST后缀是 Unity 的约定只要你在 Properties 里声明了_MainTex引擎会自动生成同名的_MainTex_ST四维向量不需要手动声明。#pragma multi_compile_fwdbase是编译变体声明它让 Unity 为不同的前向光照情况有没有阴影、有没有光照贴图等生成对应的变体。如果你发现阴影显示不正常先检查这一行有没有写。UnityWorldSpaceLightDir(i.worldPos)返回的是从表面指向光源的未归一化向量所以外面要套一层normalize。这个函数内部处理了方向光、点光源、聚光灯的不同计算直接用它比自己拿_WorldSpaceLightPos0去判断光源类型要省事得多。saturate(dot(N, L))中的saturate等价于clamp(x, 0, 1)作用是把背光的负值截断为 0避免出现“背面被负漫反射照亮变黑斑”的问题。这一步很多初学者会漏掉表现为模型背光面出现奇怪的暗色噪点。fixed4和float4的差别是精度。fixed在大多数平台上是 11 位定点数范围 -2 到 2适合存颜色float是 32 位浮点适合存坐标。用错精度会导致移动端出现色阶、位置抖动等问题后文最佳实践里会专门讲。5.4 运行与验证创建材质后把 Shader 拖到材质的 Shader 槽里贴上任意一张网格贴图场景里放一个方向光和几个不同朝向的球体。验证要点有三条第一转动光源方向高光应该跟着光源在球面上移动并且高光斑的大小随_Gloss增大而收窄。如果高光完全不出现检查_SpecularColor是不是黑色的以及模型法线有没有导入。第二把_Gloss调到 1高光应该变得非常宽几乎覆盖半个球面调到 128高光应该收缩成一个亮点。这个变化能直观说明pow函数指数项的作用。第三把光源关掉模型应该只剩下环境光形成的均匀底色。如果关掉光源后模型完全变黑说明环境光那一行没有生效检查Lighting.cginc是否包含。5.5 进阶给同一个 Shader 加描边 Pass理解了 Pass 的顺序执行之后可以动手加一个描边效果。思路是新增一个 Pass放在原 Pass 之前用Cull Front只渲染背面并在顶点着色器里沿法线方向外扩顶点这样正面被原模型挡住只留下背面形成的一圈边缘。// 追加到同一个 SubShader 中放在原 Pass 之前 Pass { Tags { LightModeAlways } 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 offset normalize(v.normal) * _OutlineWidth; float4 newVertex v.vertex float4(offset, 0); o.pos UnityObjectToClipPos(newVertex); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG }这个 Pass 需要在Properties里补上_OutlineWidth (描边宽度, Range(0, 0.1)) 0.02和_OutlineColor (描边颜色, Color) (0,0,0,1)。跑起来之后你会看到描边宽度在平坦区域和锐角区域表现不一样因为法线外扩在顶点法线不连续的地方会产生裂缝。工业界的做法是预先烘焙一张平滑法线贴图或者用屏幕空间后处理做描边这就是“会写”之后才会遇到的进阶问题。6. 迁移到 URP同一套数学完全不同的写法6.1 内置管线与 URP 的核心差异如果你直接把上面的代码扔进 URP 项目最可能的结果是模型变成粉红色或者完全不受光照影响。原因有三个第一URP 不识别CGPROGRAM它要求使用HLSLPROGRAM并且必须包含 URP 的 ShaderLibrary 文件。第二光照信息不再是_LightColor0这样的全局变量你需要调用GetMainLight()主动获取。这是 URP 为了支持多光源和可编程渲染管线做的重构。第三SubShader 的 Tags 里必须声明RenderPipelineUniversalPipeline否则 URP 根本不会考虑这个 Shader。6.2 完整 URP 版本代码保存为Assets/Shaders/URP/URPBlinnPhongSimple.shader。// 文件路径Assets/Shaders/URP/URPBlinnPhongSimple.shader Shader Custom/URP/URPBlinnPhongSimple { Properties { _BaseMap (基础贴图, 2D) white {} _BaseColor (基础颜色, Color) (1,1,1,1) _SpecularTint (高光颜色, Color) (1,1,1,1) _Gloss (光泽度, Range(1, 128)) 32 } SubShader { Tags { RenderType Opaque Queue Geometry RenderPipeline UniversalPipeline } Pass { Name ForwardLit Tags { LightMode UniversalForward } 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 CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half4 _SpecularTint; half _Gloss; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); 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; }; Varyings vert (Attributes IN) { Varyings OUT; VertexPositionInputs posInputs GetVertexPositionInputs(IN.positionOS.xyz); VertexNormalInputs norInputs GetVertexNormalInputs(IN.normalOS); OUT.positionCS posInputs.positionCS; OUT.positionWS posInputs.positionWS; OUT.normalWS norInputs.normalWS; OUT.uv TRANSFORM_TEX(IN.uv, _BaseMap); return OUT; } half4 frag (Varyings IN) : SV_Target { half4 baseColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; float3 N normalize(IN.normalWS); Light mainLight GetMainLight(); float3 L normalize(mainLight.direction); float3 V normalize(GetWorldSpaceViewDir(IN.positionWS)); float3 H normalize(L V); half NdotL saturate(dot(N, L)); half3 diffuse baseColor.rgb * mainLight.color * NdotL; half NdotH saturate(dot(N, H)); half3 specular _SpecularTint.rgb * mainLight.color * pow(NdotH, _Gloss); half3 ambient SampleSH(N) * baseColor.rgb; return half4(diffuse specular ambient, baseColor.a); } ENDHLSL } } }6.3 CBUFFER 与 SRP Batcher 的关系URP 代码里最容易被忽略、但又最影响性能的是这一段CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half4 _SpecularTint; half _Gloss; CBUFFER_ENDCBUFFER的作用是把所有“每个材质不同”的属性打包到一个常量缓冲区里。SRP Batcher 在运行时对比两个材质的这个缓冲区只要内容相同、布局一致就能把它们的材质切换开销降到极低从而合批。如果某个属性散落在 CBUFFER 外面SRP Batcher 会直接判定这个 Shader 不兼容退回传统的 SetPass Call 模式。在场景里有几百个物体的时候这个差别非常明显。需要注意的是纹理采样器TEXTURE2D和SAMPLER声明必须放在 CBUFFER 外面因为它们不是常量而是资源绑定。把纹理放进 CBUFFER 会导致编译报错。6.4 运行验证与对比在 URP 项目里创建材质、指定这个 Shader场景中放一个方向光。验证方式与内置管线版本一致但要额外确认一件事打开 Frame Debugger看 SRP Batcher 那一栏是不是显示SRP Batch而不是No compatible shaders。如果是后者说明你的 CBUFFER 布局或者声明顺序有问题。一个常见的坑是同一个材质的多个 Shader 变体之间UnityPerMaterial里的变量顺序必须完全一致否则 SRP Batcher 会认为缓冲区布局不匹配。所以在给 Shader 加新属性时统一追加到 CBUFFER 末尾不要插在中间。7. 面试高频问题与现场排查思路7.1 面试官最常问的五个问题第一个问题通常是“Lambert、Phong 和 Blinn-Phong 有什么区别”。回答的关键不是说哪个更好而是说清楚计算方式Lambert 只有漫反射Phong 用反射向量和视线向量的点积算高光Blinn-Phong 用半程向量替代反射向量少一次反射计算并且在掠射角下高光不会突然消失。能说出“半程向量等于光照方向加视线方向再归一化”基本就通过了。第二个问题是“为什么法线要从模型空间转到世界空间而不是直接用”。答案要落在非等比缩放上说明必须用逆转置矩阵并指出 Unity 的UnityObjectToWorldNormal已经封装了这个逻辑。第三个问题是“透明物体为什么会闪烁、怎么解决”。要回答渲染顺序、Queue标签、ZWrite Off、以及透明物体无法完美排序的本质原因。如果面试官追问可以补一句“所以透明物体通常要尽量避免大面积重叠或者用 Alpha Test 替代”。第四个问题是“Shader 变体是什么为什么会爆炸”。要说明每个#pragma multi_compile和shader_feature都会生成一份独立的编译结果多个关键字叠加是指数增长。实际项目中要通过shader_feature替代multi_compile、在打包设置里剥离不需要的变体、以及 Shader Variant Collection 来控制。第五个问题是“怎么优化一个 Shader”。不要只答“少用 if”要从精度half替代float、贴图采样次数、光照模型复杂度、变体数量、Overdraw 这几个维度展开并且强调先用 Profiler 定位再优化。7.2 常见现象与排查表写 Shader 的过程中遇到的问题绝大多数都能归到下面几类问题现象常见原因解决思路材质显示为粉红色Shader 编译失败或用了不匹配管线的 Shader打开 Console 看编译错误确认 URP 项目用 HLSLPROGRAM 且 Tags 正确模型完全不受光照只有纯色缺少LightMode标签或没有引入光照库文件检查 Pass 的Tags内置管线确认引入了Lighting.cginc高光位置随相机移动而漂移视线向量计算时用了错误的坐标空间确认V由世界空间位置计算且已归一化模型表面出现黑色斑块法线未归一化或dot结果未做saturate片元着色器里再归一化法线背光面用saturate截断贴图平铺设置无效缺少_MainTex_ST声明或没用TRANSFORM_TEX手动补声明或改用TRANSFORM_TEX封装透明物体互相遮挡ZWrite未关闭或Queue设置错误设为ZWrite Off和QueueTransparentURP 下 SRP Batcher 不生效属性没有全部放进CBUFFER或顺序不一致统一放入UnityPerMaterial保持所有变体顺序一致移动端出现色带或闪烁精度不够用了fixed存坐标位置相关用float颜色用half避免fixed存坐标描边在硬边模型上断裂顶点法线不连续外扩方向不一致烘焙平滑法线贴图或改用后处理描边排查顺序建议固定下来先看 Console 报错再看 Frame Debugger 里的 Pass 是否被调用然后检查坐标空间和归一化最后才是数学公式。绝大多数问题出在前两步而不是公式本身。8. 工程最佳实践与性能优化8.1 变体管理要从第一天做起#pragma multi_compile的代价比很多人想象的大。一个关键字会产生两份变体四个关键字就是十六份如果全局还有阴影、雾效、光照贴图等关键字叠加一个简单的 Shader 很容易编译出上百个变体直接影响包体和加载时间。实践原则是运行时真正需要动态切换的用multi_compile只在编辑器里调试或者通过材质开关控制的用shader_feature。后者只会在有材质实际使用时才编译能省掉大量无用变体。另外在 Project Settings → Graphics 里配置 Shader Stripping把不需要的光照模式变体剥掉。8.2 精度、分支与采样次数移动端 GPU 对精度很敏感。位置、UV、世界坐标这类需要大范围数值的变量用float颜色、光照强度这类范围有限的用halffixed现在在多数平台上已经等价于half不建议继续使用容易产生误导。关于分支GPU 是 SIMD 架构同一个 warp 内的像素走不同分支会导致两条分支都执行。所以片元着色器里应该尽量避免依赖像素级数据的动态分支。如果分支条件是材质级的常量比如一个开关关键字用#pragma shader_feature配合编译期分支性能远好于运行时if。纹理采样是另一个大头。每多一次SAMPLE_TEXTURE2D就多一次显存带宽开销移动端尤其明显。常见的优化是把遮罩、粗糙度、金属度打包到同一张贴图的不同通道R、G、B、A用一次采样拿到四份数据。8.3 命名、注释与可维护性Shader 代码的可读性在团队协作中非常重要。建议遵循几条简单规则变量命名带空间和用途后缀例如worldNormal、tangentWS、uvMain、maskRoughness。属性在 Properties 里加中文显示名让美术在材质面板上能看懂。每个 Pass 开头用一行注释说明它的作用例如// 描边 PassCull Front 法线外扩。复杂的数学推导在旁边写清楚公式来源三个月后你会感谢自己。另外把公共的变换、光照计算抽成.cginc或.hlsl文件放在Assets/Shaders/Includes/下多个 Shader 共享避免同一段代码复制十几遍。8.4 上线前的检查清单在把 Shader 提交到正式包之前至少确认这几件事在目标最低端机型上跑过一遍帧率没有明显下降用 Frame Debugger 确认 Draw Call 数量符合预期确认 SRP Batcher 或 GPU Instancing 生效确认没有遗留的调试代码和未使用属性确认所有变体都已被正确剥离或包含确认在关闭后处理、关闭阴影的降级配置下依然能正常显示。9. 从会改到会写接下来的练习路线把上面的内容串起来看从“会改”到“会写”其实只需要跨过三个门槛能独立写出一个带光照的 Shader、能说清楚每个坐标空间和语义的作用、能在出问题时按固定顺序排查。本文的 Blinn-Phong 案例覆盖了前两个排查表覆盖了第三个。接下来的练习建议按难度递进每一题都要求脱离参考代码独立完成第一题把 Blinn-Phong 改成只显示法线颜色把normal * 0.5 0.5直接输出用来验证法线是否正确第二题做一个顶点上下浮动的波浪效果练习在顶点着色器里修改位置第三题做一个基于世界坐标 Y 轴的溶解效果配合clip函数练习片元丢弃第四题把 Blinn-Phong 扩展成支持阴影投射和接收第五题把它迁移到 URP 并开启 SRP Batcher 验证。每一题做完都建议用 Frame Debugger 看一遍确认 Pass 的执行顺序和渲染状态符合预期。Shader 这门技能的反馈很直接写对了立刻能看到效果写错了也能立刻看到异常这种即时反馈是它比很多后端技术更容易上手的原因。如果本文对你有帮助可以收藏起来等下次面试前再翻一遍排查表。真正拉开差距的从来不是记住多少函数名而是遇到一个没做过的效果时你知道该从顶点数据、坐标空间还是光照模型入手去拆解它。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑