
1. 从“黑盒”到“白盒”为什么我们需要深入内建着色器在Unity开发中尤其是涉及图形渲染、性能优化或者需要实现特定视觉效果时我们经常会遇到一个看似简单却让人头疼的问题为什么我的材质看起来和官方示例不一样为什么调整这个参数没有达到预期的效果很多时候答案就藏在那些我们默认使用的、被Unity打包好的“内建着色器”Built-in Shaders里。这些内建着色器比如Standard、Standard (Specular setup)、Unlit甚至那些Legacy的Diffuse、Bumped它们就像引擎提供给我们的“预制菜”。我们拿来就用调整几个公开的Color、Texture滑块就能得到不错的效果。但一旦你想“改个配方”比如让Standard材质在某些情况下不接收阴影或者让Unlit纹理在特定距离开始淡出你就会发现无从下手。因为这些着色器的具体实现对大多数开发者而言是一个“黑盒”。所以解析内建着色器源码本质上是一个“拆解预制菜学习烹饪原理”的过程。这不是为了让我们去修改Unity官方的源码通常也不建议这么做而是为了达成几个核心目的知其所以然理解Metallic滑块背后是如何影响漫反射和高光计算的明白Normal Map的蓝色通道在切线空间下扮演什么角色。这能让你从“调参数碰运气”变成“有目的地设计材质”。深度定制当内建着色器无法满足需求时比如需要一套基于物理但渲染流程完全自定义的角色皮肤着色器内建着色器的源码就是最好的蓝图和起点。你可以复制一份在其基础上大刀阔斧地修改。性能洞察通过源码你能清楚地看到一次Standard着色器渲染需要多少纹理采样、进行多少次复杂计算如基于图像的照明、环境光遮蔽等。这对于移动端性能优化至关重要你可以据此决定是使用Standard的简化版Mobile变体还是自己写一个更轻量的。问题排查当遇到诡异的渲染Bug比如法线贴图在某些设备上失效、透明混合顺序错乱对照着源码一步步分析渲染状态和计算过程往往是定位问题的唯一途径。在之前的解析中我们可能已经探讨了着色器的基本结构、Properties和SubShader的关系。本篇我们将深入到更核心的部分表面着色器Surface Shader的编译展开、光照模型Lighting Model的奥秘以及顶点/片元着色器Vertex/Fragment Shader的完整管线。我们会以Standard着色器为主要标本因为它最复杂也最具代表性同时也会对比Unlit这种简单着色器来理解差异。2. 解剖“Standard”着色器一个复杂的生态系统Unity的Standard着色器文件通常位于[Unity安装路径]/Editor/Data/CGIncludes或通过UnityShader包引入其核心是一个使用Surface Shader语法的.shader文件。但直接看这个文件你可能会困惑因为它看起来并不长没有一大堆的vert和frag函数。这就是表面着色器的“魔法”它是一个高级抽象层。2.1 表面着色器Unity的“语法糖”与编译展开当我们写下这样的表面着色器代码块时#pragma surface surf Standard fullforwardshadows这行指令告诉Unity的表面着色器编译器“这里有一个表面函数叫surf使用名为Standard的光照模型并且需要生成支持所有正向渲染路径阴影的完整着色器变体。”这个“编译展开”的过程是理解内建着色器的关键。Unity的编译器UnityShaderCompiler会读取这段高级代码然后根据我们指定的参数如光照模型、阴影类型、渲染路径等自动生成最终交付给GPU的、完整的顶点/片元着色器代码。这个生成的代码才是真正在GPU上运行的东西。我们可以通过Unity编辑器一窥这个生成过程。在Project面板中找到你的材质使用的Standard着色器或者创建一个使用Surface Shader的测试着色器在Inspector面板底部有一个“Compiled and show code”的按钮。点击它你会看到一个包含了所有展开后变体的庞大代码文件。这个文件通常有数千行包含了为不同渲染路径Forward/Deferred、不同光照类型平行光、点光源、聚光灯、不同阴影设置无阴影、硬阴影、软阴影等生成的所有Pass和Shader Variants。注意这个生成的代码是只读的并且每次材质或着色器设置改变都可能重新生成。它的价值在于学习和调试。当你自定义的表面着色器行为异常时查看生成的代码可以帮助你定位问题比如发现某个变量没有被正确传递或者生成的数学计算不符合预期。2.2 Standard光照模型PBR的核心公式#pragma surface surf Standard中的Standard指的就是Unity内置的基于物理的渲染PBR光照模型。这个模型的具体实现位于UnityStandardCore.cginc等包含文件中。它主要包含两个部分光照函数Lighting Function和表面函数Surface Function。表面函数surf函数是我们的责任。在这个函数里我们需要填充一个SurfaceOutputStandard结构体。这个结构体定义了片元像素的物理属性struct SurfaceOutputStandard { fixed3 Albedo; // 漫反射颜色基础色 fixed3 Normal; // 切线空间法线 fixed3 Emission; // 自发光 fixed Metallic; // 金属度 (0非金属, 1金属) fixed Smoothness; // 光滑度 (0粗糙, 1光滑) fixed Occlusion; // 环境光遮蔽 fixed Alpha; // 透明度 };在Standard着色器的surf函数中你会看到它从_MainTex采样得到albedo从_MetallicGlossMap如果存在中分离出metallic和smoothness处理法线贴图并赋值给Normal。这里的一个关键细节是贴图的采样与转换。例如光滑度信息通常存储在_MetallicGlossMap的Alpha通道或_MainTex的Alpha通道取决于设置并且smoothness值可能不是直接使用会乘以一个_Glossiness系数进行缩放。光照函数LightingStandard是Unity内置的、我们通常不直接修改的部分。它接收SurfaceOutputStandard的数据、光照信息方向、颜色、衰减和视角方向然后计算最终的颜色。其内部遵循了经典的PBR微表面模型通常是Cook-Torrance或GGX BRDF计算过程大致如下菲涅尔效应Fresnel根据视角与法线的夹角计算反射的强度。金属的菲涅尔效应更强。法线分布函数NDF描述微表面法线朝向的分布决定高光的大小和形状。Smoothness值直接影响这个函数。几何函数Geometry Function描述微表面遮挡和阴影的效果影响高光的亮度和边缘。能量守恒确保反射的光能不会超过入射光能。金属材质Metallic接近1的漫反射部分Albedo会趋于黑色因为大部分光被反射高光了。一个重要的实践心得是Metallic和Smoothness这两个参数是强耦合的。在真实的PBR工作流中它们通常一起由美术人员在Substance Painter或类似工具中绘制生成。在代码层面LightingStandard函数会使用Metallic值来在漫反射Lambert或Disney模型和高光Cook-Torrance之间进行插值混合。当Metallic1时Albedo颜色几乎完全用于高光反射计算漫反射部分可以忽略不计。2.3 变体Variants的代价编译文件为何如此庞大点击“Show compiled code”后看到的数千行代码绝大部分是着色器变体。变体是为了应对不同的渲染条件而预先编译好的不同版本的着色器。Standard着色器之所以强大也正因为其变体数量极其庞大。变体主要由以下#pragma指令和材质关键字#pragma shader_feature触发渲染路径#pragma multi_compile_fwdbase(用于正向渲染基础Pass处理主平行光和环境光) 和#pragma multi_compile_fwdadd(用于正向渲染附加Pass处理其他点光源/聚光灯)。每个都会为不同光源类型和阴影设置生成变体。阴影fullforwardshadows会生成支持所有光源软阴影的变体。光照贴图#pragma multi_compile LIGHTMAP_ON LIGHTMAP_OFF。动态全局光照DynGI#pragma multi_compile DYNAMICLIGHTMAP_ON DYNAMICLIGHTMAP_OFF。雾效#pragma multi_compile_fog。材质特性开关例如#pragma shader_feature _NORMALMAP表示法线贴图是可选的。如果材质启用了_NORMALMAP关键字Unity会编译包含法线计算代码的变体否则编译一个更简单的变体。变体爆炸是移动端和大型项目需要严肃对待的问题。每一个变体都是独立的着色器程序会占用磁盘空间增大构建包体和运行时内存着色器加载。一个配置齐全的Standard材质其变体数量轻松过千。这就是为什么Unity提供了Standard (Specular setup)、Mobile变体以及为什么我们经常需要为移动端创建简化版着色器——根本目的就是为了减少变体数量。实操技巧在项目设置Edit - Project Settings - Graphics的Shader Stripping部分可以尝试关闭一些用不到的特性如Soft Shadows来减少变体。更根本的方法是为你项目中大量使用的材质创建自定义的、功能明确且变体可控的表面着色器。3. 对比研究“Unlit”着色器的极简主义理解了复杂的Standard再回头看Unlit着色器会有一种豁然开朗的感觉。Unlit顾名思义不受光照影响。它的源码是理解基础顶点/片元着色器管线的绝佳材料因为它几乎没有“魔法”。3.1 一个纯粹的顶点/片元着色器结构Unlit着色器通常不使用Surface Shader语法而是直接编写vert和frag函数。一个典型的Unlit/Texture着色器核心结构如下Shader Unlit/SimpleTexture { Properties { _MainTex (Texture, 2D) white {} } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; // _ST是Scale/Transform信息 v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // 模型空间-裁剪空间 o.uv TRANSFORM_TEX(v.uv, _MainTex); // 应用纹理的缩放和偏移 return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 采样纹理 return col; } ENDCG } } }这里清晰地展示了图形渲染管线中一个Pass的基本流程顶点着色器vert接收模型原始的顶点数据appdata进行坐标变换从模型空间到裁剪空间这是为了后续的裁剪和屏幕映射并准备传递给片元着色器的数据v2f。片元着色器frag接收经过光栅化插值后的v2f数据对每个像素片元计算最终颜色。在这里就是简单地采样纹理。UnityObjectToClipPos和TRANSFORM_TEX这些宏定义在UnityCG.cginc中它们封装了与Unity渲染引擎交互的细节让我们无需自己处理矩阵乘法和纹理变换。3.2 从“Unlit”看“Standard”的包装对比两者我们可以发现Surface Shader本质上是一个代码生成器。它把我们定义的surf函数和选择的Lighting Model与类似Unlit这样的基础vert/frag模板、以及各种光照和阴影计算代码自动组装起来生成了最终那个复杂的、多Pass的、多变体的着色器程序。当我们写surf函数时我们只是在填写“表面属性问卷”。而Unlit着色器则是我们亲手编写从问卷到最终答案的每一个步骤。因此学习内建着色器源码的终极目标应该是为了在必要时能够脱离Surface Shader的舒适区亲手编写或深度定制符合项目特定需求的顶点/片元着色器。例如你需要实现一个特殊的溶解效果、一个基于深度的边缘光、或者一个极简的UI遮罩着色器从Unlit模板开始修改往往是最直接高效的。4. 关键包含文件CGIncludes探秘Unity内建着色器的强大功能很大程度上依赖于一系列.cginc包含文件。这些文件像是一个个功能模块库存放在Unity的安装目录下。理解它们的分工是读懂源码的路线图。UnityCG.cginc最核心、最基础的库。提供了大量的工具函数和宏例如UnityObjectToWorldNormal: 将法线从模型空间转换到世界空间处理非统一缩放。DecodeNormalMap: 解码法线贴图从[0,1]范围映射到[-1,1]范围。ShadeSH9: 计算球谐光照用于环境漫反射光。各种空间转换函数UnityObjectToClipPos,WorldSpaceViewDir等。雾效、阴影纹理采样相关的辅助函数。 几乎所有自定义着色器都会包含它。Lighting.cginc包含了传统兰伯特Lambert和半兰伯特Half Lambert等经典光照模型函数。Standard着色器虽然主要用UnityStandardCore.cginc但一些辅助计算可能仍会引用这里。UnityStandardCore.cginc / UnityStandardCoreForwardSimple.cgincPBR渲染的核心。前者用于延迟渲染路径后者用于正向渲染路径。它们定义了SurfaceOutputStandard结构体、LightingStandard光照函数以及组织整个渲染流程的核心函数如FragmentForwardBase。UnityPBSLighting.cginc实现了基于物理的BRDF函数如BRDF1_Unity_PBS。这是LightingStandard函数底层调用的数学库包含了菲涅尔、法线分布、几何遮蔽等复杂计算的具体实现。AutoLight.cginc阴影和光照衰减的“黑科技”。这个文件通过一系列宏如SHADOW_COORDS,TRANSFER_SHADOW,SHADOW_ATTENUATION极大地简化了在正向渲染路径下接收阴影的代码。它自动处理了阴影坐标的计算和阴影贴图的采样根据不同的光源类型和阴影设置选择正确的采样方式。在表面着色器中fullforwardshadows指令的魔力就来源于此。UnityGlobalIllumination.cginc处理光照贴图Lightmaps、光照探针Light Probes和动态全局光照DynGI的数据采样和混合。阅读这些.cginc文件是提升Shader编程能力的必经之路。一开始你可能会被里面大量的宏和条件编译#ifdef吓到。一个有效的方法是结合一个具体的着色器比如Standard在编译后的代码中搜索某个函数或变量的使用然后反向追踪到.cginc文件中的定义像侦探一样理清调用链。例如跟踪surf函数输出的Normal是如何最终被LightingStandard函数使用的。5. 实战从源码理解到自定义实践理论最终要服务于实践。我们如何将阅读源码的收获转化为实际项目中的能力这里有几个具体的场景。5.1 场景一为Standard着色器添加自定义功能假设我们需要在Standard着色器的基础上增加一个“边缘发光”Rim Light效果。直接修改Unity内置文件是危险且不便于协作的。正确的做法是复制并创建自定义着色器在Unity中找到Standard着色器文件可以通过创建Standard材质查看其Shader路径在项目内创建一个副本重命名为MyStandardWithRim。修改Properties在属性块中添加边缘颜色和强度参数。_RimColor (Rim Color, Color) (1,1,1,1) _RimPower (Rim Power, Range(0.5, 8.0)) 3.0修改SurfaceOutputStandard结构体不需要。我们可以将边缘光作为附加的发射光Emission来处理。在surf函数的末尾计算基于视角和法线夹角的边缘因子并叠加到Emission上。void surf (Input IN, inout SurfaceOutputStandard o) { // ... 原有的albedo, normal, metallic等计算 ... fixed4 c tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; // 计算边缘光 float3 viewDir normalize(IN.viewDir); float rim 1.0 - saturate(dot(viewDir, o.Normal)); o.Emission _RimColor.rgb * pow(rim, _RimPower); }这里的关键点我们通过阅读源码知道SurfaceOutputStandard结构体中的Emission是独立于光照计算的它会直接加到最终颜色上。Input结构体中默认包含viewDir世界空间视角方向变量这是表面着色器编译器自动为我们提供的。处理编译依赖确保你的自定义着色器能正确编译可能需要手动包含一些.cginc文件或者将整个Standard着色器的依赖关系复制过来。通常直接复制整个Standard着色器文件并修改是最稳妥的。5.2 场景二诊断渲染问题——法线贴图为何“失效”一个常见的问题是法线贴图在导入后在材质上看不到凹凸效果或者效果完全错误。通过阅读Standard着色器中处理法线贴图的部分我们可以系统性地排查纹理导入设置源码中采样法线贴图后会使用UnpackNormal函数。这个函数期望的输入是“DXT5nm”压缩格式或常规的RGB法线图。在Unity中必须将纹理的Texture Type设置为Normal Map这样Unity才会在导入时进行正确的压缩和转换并设置sRGB (Color Texture)为false。切线空间 vs 世界空间Standard着色器默认在切线空间Tangent Space下处理法线贴图。这意味着法线贴图中存储的向量是相对于每个顶点自身的切线空间的。在surf函数中你会看到类似o.Normal UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap))的代码。如果模型没有正确的切线Tangent信息这个计算就会出错。检查模型的导入设置确保Normals Tangents选项不是None或Calculate模式不正确。Shader中的计算流程在编译后的代码中跟踪法线向量的变换。通常流程是从贴图解压出切线空间法线 - 与从顶点着色器插值来的世界空间法线/切线向量进行组合 - 转换到世界空间供光照计算使用。如果中间任何一步的矩阵或向量空间错了结果就会不对。通过对照源码你就能明确知道问题可能出在资产导入管线、模型数据还是着色器计算逻辑上而不是盲目地尝试调整材质球上的浮点参数。5.3 场景三性能分析与简化面对一个使用了复杂Standard材质的场景如何优化阅读源码能给你最直接的指导。分析变体使用Frame Debugger或Shader Variant Collection工具查看运行时实际加载和使用了哪些着色器变体。如果发现大量用不到的变体例如你的项目是纯室内场景却编译了大量处理动态全局光照的变体就可以考虑使用简化版着色器。简化光照模型如果你不需要PBR的物理准确性可以基于Standard源码创建一个简化版。例如移除Metallic工作流只保留Specular或甚至使用更简单的Blinn-Phong高光模型。移除或简化环境光遮蔽、细节法线贴图等高级特性。减少纹理采样次数例如将金属度、光滑度、环境光遮蔽合并到一张贴图的不同通道。编写移动端专用着色器参考Mobile目录下的着色器或者基于Unlit着色器只添加一个主平行光的光照计算简单的兰伯特漫反射冯/布林高光并严格控制变体数量例如只支持平行光阴影不支持点光源阴影。这样的着色器在低端手机上可能比Standard快数倍。阅读内建着色器源码尤其是Standard这种工业级实现是一个持续的学习过程。它不仅能解决你手头的具体问题更能从根本上提升你对实时图形渲染的理解。下次当你调整材质球上的滑块时你脑海中浮现的将不再是一个模糊的“变亮变暗”的概念而是一系列清晰的数学公式和渲染管线中的具体步骤。这种从“使用者”到“理解者”乃至“创造者”的转变正是深入源码带来的最大价值。