news 2026/10/26 4:30:27

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity Shader从会改到会写:Blinn-Phong、URP与面试实战

做 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 { "RenderType"="Opaque" "Queue"="Geometry" } LOD 200 Pass { Tags { "LightMode"="ForwardBase" } // 渲染状态与着色器代码 } } 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 光照 Shader

5.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 { "RenderType"="Opaque" "Queue"="Geometry" } LOD 200 Pass { Tags { "LightMode"="ForwardBase" } 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 { "LightMode"="Always" } 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 里必须声明"RenderPipeline"="UniversalPipeline",否则 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_END

CBUFFER的作用是把所有“每个材质不同”的属性打包到一个常量缓冲区里。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和Queue"="Transparent"
URP 下 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;颜色、光照强度这类范围有限的用half;fixed现在在多数平台上已经等价于half,不建议继续使用,容易产生误导。

关于分支,GPU 是 SIMD 架构,同一个 warp 内的像素走不同分支会导致两条分支都执行。所以片元着色器里应该尽量避免依赖像素级数据的动态分支。如果分支条件是材质级的常量(比如一个开关关键字),用#pragma shader_feature配合编译期分支,性能远好于运行时if。

纹理采样是另一个大头。每多一次SAMPLE_TEXTURE2D就多一次显存带宽开销,移动端尤其明显。常见的优化是把遮罩、粗糙度、金属度打包到同一张贴图的不同通道(R、G、B、A),用一次采样拿到四份数据。

8.3 命名、注释与可维护性

Shader 代码的可读性在团队协作中非常重要。建议遵循几条简单规则:

变量命名带空间和用途后缀,例如worldNormal、tangentWS、uvMain、maskRoughness。属性在 Properties 里加中文显示名,让美术在材质面板上能看懂。每个 Pass 开头用一行注释说明它的作用,例如// 描边 Pass:Cull 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 这门技能的反馈很直接,写对了立刻能看到效果,写错了也能立刻看到异常,这种即时反馈是它比很多后端技术更容易上手的原因。

如果本文对你有帮助,可以收藏起来,等下次面试前再翻一遍排查表。真正拉开差距的从来不是记住多少函数名,而是遇到一个没做过的效果时,你知道该从顶点数据、坐标空间还是光照模型入手去拆解它。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 4:29:48

Agent平台连接池耗尽导致超时?从根因到解决方案全复盘

1. 故障背景:Agent Platform 为什么会出现集体超时说起来这件事已经过去一段时间了,但那次线上超时故障的每个细节我都还记得很清楚。当时我负责的 Agent Platform 正处于业务量快速上涨的阶段,平台承担着大量 AI Agent 任务的统一调度、编排…

作者头像 李华
网站建设 2026/10/26 4:29:45

Ubuntu 18.04 上构建 JDK+Tomcat+Redis 单镜像的完整指南

1. 为什么要在 Ubuntu 18.04 上自己构建 JDKTomcatRedis 镜像先把结论摆出来:这个组合看起来像是“老派 Java Web 全家桶”,但在不少实际项目里依然是刚需。Ubuntu 18.04 作为长期支持版本,很多企业内网、边缘设备、教学实验环境还在跑&#…

作者头像 李华
网站建设 2026/10/26 4:29:29

Matlab单像素成像重建算法选型:五类方法对比与避坑

简介:面向单像素成像学习与算法复现的Matlab代码包,围绕同一成像框架对比差分鬼成像、梯度下降、共轭梯度下降、泊松最大似然、交替投影、稀疏表示压缩感知与全变分压缩感知七类重建方法,适合具备Matlab基础、希望理解压缩感知与迭代重建差异…

作者头像 李华
网站建设 2026/10/26 4:28:21

DeepSeek 800亿融资背后:开源大模型与开发者技术选型实践

800亿,按当前大模型的融资行情来看,是一个会让很多人放下手里的活儿、盯着标题看两秒的数字。更让人在意的是,这次的主角是DeepSeek,一个靠开源模型和技术社区一格格累积口碑的本土大模型项目。有人在社区里喊出“两弹一星时刻”的…

作者头像 李华
网站建设 2026/10/26 4:27:38

车云数据架构决定智能诊断深度:从采集到标签回流的实践

做车云做了五年多,参与了几个车型的远程诊断、故障预测、健康管理项目,有一个感悟越来越强烈:很多车企的智能诊断项目做不深,表面看是AI模型不行、算法不给力,实际上瓶颈基本都卡在车云数据架构上。模型只是浮在水面上…

作者头像 李华
网站建设 2026/10/26 4:26:30

物联网技术在猪笼草生长环境模拟中的应用

1. 项目概述:当科技遇上植物学去年夏天,我在植物园第一次看到猪笼草捕食昆虫的场景,那种精妙的陷阱结构让我着迷。但作为北方居民,想在家养一株健康的猪笼草简直难如登天——温度、湿度、光照稍有偏差就会导致植株枯萎。这促使我开…

作者头像 李华