Unity镜面反射Shader:从Phong到Blinn-Phong光照模型实现与优化

发布时间:2026/8/4 3:02:23
Unity镜面反射Shader:从Phong到Blinn-Phong光照模型实现与优化 1. 项目概述为什么镜面反射Shader是Unity开发者的必修课在Unity里做项目尤其是涉及到高品质视觉效果的游戏、模拟器或者交互应用你迟早会碰到一个坎想让你的水面、金属、玻璃或者任何光滑表面看起来“真实”。这个“真实感”的核心往往就落在镜面反射Specular Reflection上。它不是简单的把环境贴图糊上去而是模拟光线在光滑表面上的物理行为产生高光亮点和清晰的环境映像。很多新手甚至一些有经验的开发者可能会直接去Asset Store找一个现成的Shader或者用Standard Shader调调参数就过去了。但当你需要定制一个独特的材质或者优化移动端性能时不理解背后的原理你就会寸步难行。这个教程就是带你从“会用”到“懂原理”再到“能自己写”。镜面反射Shader不仅仅是加个CubeMap立方体贴图那么简单。它涉及到光照模型的计算、法线向量的处理、视角方向的引入以及如何在性能与效果之间做权衡。无论是你想做一个波光粼粼的湖面一个锃光瓦亮的跑车漆面还是一个科幻感十足的金属走廊掌握自定镜面反射Shader的能力都能让你从素材的“使用者”晋升为效果的“创造者”。接下来我会拆解最经典的Phong和Blinn-Phong光照模型带你手写一个可用的镜面反射Shader并深入探讨在实际项目中你会遇到的各种坑和优化技巧。2. 核心原理与光照模型拆解要写镜面反射首先得明白光是怎么在表面上“弹开”的。我们人眼能看到物体是因为光线从光源发出照射到物体表面再反射进入我们的眼睛。镜面反射特指那种在光滑表面上光线沿着“入射角等于反射角”的规律集中向一个方向反射的现象。这个方向如果正好对准你的眼睛你就会看到一个非常亮的高光点。在Shader编程中我们不可能去追踪每一条光线所以要用数学模型来近似。最经典的两个模型就是Phong和Blinn-Phong。2.1 Phong光照模型最直观的理解Phong模型的计算思路非常符合直觉我需要找到光线的反射方向向量 R然后看看这个反射方向与我的视线方向向量 V有多接近。两者越接近说明视线越接近完美的反射光路镜面高光就应该越强。计算过程分几步走准备向量我们需要四个核心向量。表面法线 N垂直于表面的方向光线方向 L从表面点指向光源视线方向 V从表面点指向摄像机以及计算出来的反射方向 R。计算反射方向 R这是关键一步。给定入射光方向 L 和法线 N反射方向 R 可以通过公式R reflect(-L, N)得到。在代码里我们可以直接用reflect函数。注意L通常定义为从光源指向表面点所以我们需要取反-L作为入射方向。计算高光强度Phong模型用反射方向R和视线方向V的点积dot product来衡量它们的接近程度。点积结果在[-1, 1]之间我们取最大值max(dot(R, V), 0)。这个值越接近1说明R和V越重合高光越强。施加高光指数直接使用点积的结果高光区域会非常“大”且“散”看起来不真实。因此我们引入一个高光指数Shininess通常记为_Gloss或_Shininess。将点积的结果进行幂运算specular pow(max(dot(R, V), 0), _Gloss)。_Gloss值越大比如128、256高光点就越集中、越小、越锐利材质看起来就越光滑像抛光金属值越小比如8、16高光就越分散材质看起来越粗糙像哑光塑料。注意Phong模型在数学上是正确的但计算反射向量R本身需要一次reflect运算在早期的硬件上开销相对较大。而且当视线方向V与反射方向R的夹角较大时高光会突然消失有时会导致高光边缘不自然。2.2 Blinn-Phong光照模型更高效的替代方案Blinn-Phong是对Phong模型的一个优化改进它不再计算反射向量R而是引入了一个新的中间向量半角向量 H。半角向量H是光线方向L和视线方向V的角平分线方向计算非常简单H normalize(L V)。然后Blinn-Phong模型用法线N和半角向量H的点积来代替Phong模型中的dot(R, V)。即specular pow(max(dot(N, H), 0), _Gloss)。为什么Blinn-Phong更常用性能更优计算H normalize(L V)通常比计算R reflect(-L, N)在大部分硬件上更快尤其是在移动平台这个优势更明显。效果更平滑Blinn-Phong的高光过渡通常比Phong更平滑、更自然尤其是在高光指数较大时能产生更宽、更柔和的高光区域视觉上常常被认为更“好看”。成为行业事实标准正因为其效率和效果的平衡Blinn-Phong模型成为了早期固定渲染管线以及许多实时渲染应用包括Unity旧版内置Shader的事实标准。在Unity的Standard Shader中其镜面反射部分就是基于类似Blinn-Phong的物理模型更精确地说是基于微表面理论的Cook-Torrance模型但Blinn-Phong是其一个简化特例构建的。对于我们自定义Shader入门从Blinn-Phong开始理解是最佳路径。3. 手把手实现一个基础镜面反射Shader理论懂了我们立刻动手。这里我们使用Unity的ShaderLab和CG/HLSL语言来编写一个最简单的Blinn-Phong镜面反射Shader。我们将创建一个新的Unlit Shader然后加入漫反射和镜面反射计算。3.1 搭建Shader基本结构首先在Unity中创建一个新的Shader文件命名为SimpleSpecular。清空默认代码我们从最基本的框架开始。Shader Custom/SimpleSpecular { Properties { // 定义在材质面板上可调的属性 _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) _SpecColor (Specular Color, Color) (1,1,1,1) // 高光颜色 _Gloss (Gloss, Range(1, 256)) 20 // 高光指数 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 为了使用光照变量需要这个编译指令 #pragma multi_compile_fwdbase #include UnityCG.cginc // 为了使用内置的光照变量如_LightColor0需要包含Lighting.cginc #include Lighting.cginc // 声明与Properties块对应的变量 sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放和偏移 fixed4 _Color; fixed4 _SpecColor; float _Gloss; // 从顶点着色器传递给片段着色器的结构体 struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; };这部分代码定义了Shader的属性、渲染标签和基本结构。_SpecColor控制高光的颜色通常是白色但也可以是金色、蓝色等来模拟特殊材质。_Gloss就是我们前面提到的高光指数。v2f结构体中我们除了传递纹理坐标和裁剪空间位置还传递了世界空间法线和世界空间顶点位置因为光照计算最好在世界空间或切线空间进行这里我们选择世界空间。3.2 顶点着色器准备世界空间数据顶点着色器的任务是将模型顶点变换到裁剪空间并计算世界空间下的法线和位置。v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 变换到裁剪空间 o.uv TRANSFORM_TEX(v.uv, _MainTex); // 应用纹理缩放偏移 // 将法线从模型空间变换到世界空间 // 使用unity_WorldToObject的逆转置矩阵来处理非均匀缩放 o.worldNormal UnityObjectToWorldNormal(v.normal); // 将顶点位置从模型空间变换到世界空间 o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; }这里的关键是UnityObjectToWorldNormal函数它正确地处理了模型可能存在的非均匀缩放比如一个球被压扁确保法线方向依然垂直于变换后的表面。这是新手极易忽略的一个点错误的世界法线会导致光照计算完全错误。3.3 片段着色器实现Blinn-Phong光照计算这是核心部分我们在每个像素片段上计算最终颜色。fixed4 frag (v2f i) : SV_Target { // 采样纹理颜色 fixed4 col tex2D(_MainTex, i.uv) * _Color; // 归一化世界空间法线 float3 worldNormal normalize(i.worldNormal); // 计算世界空间下的视线方向从表面指向摄像机 float3 viewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 计算环境光简化处理 fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * col.rgb; // 计算漫反射Lambert // 获取主平行光的方向和颜色 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); // 如果光源是点光源方向需要计算lightDir normalize(光源世界坐标 - i.worldPos); fixed3 lightColor _LightColor0.rgb; // 兰伯特系数法线与光线方向的点积取正值 float lambert max(0, dot(worldNormal, lightDir)); fixed3 diffuse lightColor * lambert * col.rgb; // 计算镜面反射Blinn-Phong // 1. 计算半角向量 float3 halfDir normalize(lightDir viewDir); // 2. 计算法线与半角向量的点积 float specularFactor max(0, dot(worldNormal, halfDir)); // 3. 应用高光指数 float specularIntensity pow(specularFactor, _Gloss); // 4. 综合光源颜色和高光颜色 fixed3 specular lightColor * _SpecColor.rgb * specularIntensity; // 最终颜色 环境光 漫反射 镜面反射 fixed4 finalColor fixed4(ambient diffuse specular, col.a); return finalColor; } ENDCG } } FallBack Diffuse // 如果Shader不支持回退到漫反射Shader }逐行解析与实操要点视线方向viewDir计算方式是摄像机世界坐标 - 表面点世界坐标然后归一化。方向是从表面指向摄像机。这是很多教程里容易写反的地方写反了高光会出现在错误的位置。光源方向lightDir对于场景中的主平行光Directional Light_WorldSpaceLightPos0.xyz直接存储了光的方向从光源指向表面。对于其他类型光源需要根据光源位置计算。_LightColor0这是Unity内置变量包含了当前处理的光源的颜色和强度。确保场景中有激活的光源否则这个值可能是黑色。pow函数的陷阱pow函数在底数为0时如果指数也是0在某些平台上可能产生未定义结果。虽然我们用了max(0, ...)确保底数非负但最佳实践是给一个极小的偏移例如pow(max(0, dot(N, H)), _Gloss)。不过对于Blinn-Phong通常直接使用是安全的。性能小贴士在片段着色器中进行normalize操作和pow操作是比较耗能的。如果模型顶点足够多可以考虑在顶点着色器计算worldNormal和viewDir对每个顶点计算一次然后通过插值传递给片段着色器。但要注意插值后的向量长度可能不为1在片段着色器中必须再次归一化否则光照会出错。把这个Shader赋给一个材质球拖到一个模型上调整_Gloss和_SpecColor你就能看到明显的镜面高光效果了。这是你完全从零开始控制的光照。4. 进阶环境反射与Cubemap应用基础的光照模型实现了针对点光源的镜面高光但对于像金属、水面这样能清晰反射周围环境的表面我们需要更强大的工具环境贴图Cubemap。Cubemap是一张包含了六个面的特殊纹理分别对应前后左右上下六个方向模拟了一个无限远的环境。4.1 采样Cubemap实现简单环境反射思路是在着色点根据视线方向V和法线N计算出反射方向R然后用这个R作为向量去采样Cubemap。修改我们的Shader添加Cubemap属性和采样代码在Properties中添加_EnvMap (Environment Map (Cube), Cube) _Skybox {} _ReflectIntensity (Reflection Intensity, Range(0, 1)) 0.5在CGPROGRAM中声明对应变量samplerCUBE _EnvMap; float _ReflectIntensity;在片段着色器中计算反射并采样在计算完Blinn-Phong高光后// 计算环境反射 // 注意这里的viewDir是从表面指向相机我们需要反射这个方向 // 反射函数reflect(入射方向, 法线)。入射方向是朝向表面的所以是 -viewDir float3 reflectDir reflect(-viewDir, worldNormal); fixed3 envReflection texCUBE(_EnvMap, reflectDir).rgb * _ReflectIntensity; // 最终颜色混合可以将反射结果加到最终颜色上或者与漫反射/高光进行混合 // 一种常见方式是用于金属表面反射很强漫反射很弱 finalColor.rgb finalColor.rgb envReflection; // 另一种更物理的方式是使用菲涅尔效应进行混合见下文。这样你的模型就会像镜子一样反射你指定的Cubemap环境了。你可以创建一个Cubemap资源或者使用Unity内置的Skybox/Cubemap材质来生成。4.2 引入菲涅尔效应Fresnel Effect现实世界中反射的强度并不是恒定的。当视线与表面法线方向接近即垂直看表面时反射较弱你更多看到表面的颜色漫反射当视线与表面几乎平行即掠射角时反射会急剧增强。这种现象就是菲涅尔效应。海边看水面近处清澈见底透射漫反射远处则像镜子一样反射天空就是这个原理。在Shader中我们可以用一个简单的公式来近似菲涅尔效应最常用的是Schlick近似fresnel baseReflectivity (1 - baseReflectivity) * pow(1 - dot(viewDir, normal), 5)其中baseReflectivity是0度角入射时的基础反射率对于非金属通常很低如0.04。修改我们的反射混合部分// 计算菲涅尔因子 float fresnelBias 0.04; // 基础反射率可调 float fresnelScale 0.5; // 影响因子可调 float fresnelPower 5.0; // 幂指数控制过渡快慢 float fresnelFactor fresnelBias fresnelScale * pow(1.0 - max(0, dot(viewDir, worldNormal)), fresnelPower); // 将菲涅尔因子限制在[0,1] fresnelFactor saturate(fresnelFactor); // 使用菲涅尔因子混合表面颜色和环境反射颜色 fixed3 finalReflection lerp(col.rgb, envReflection, fresnelFactor); // 然后将finalReflection替换掉原来的col.rgb参与后续计算或者直接与高光等叠加 finalColor.rgb ambient diffuse * (1.0 - fresnelFactor) specular envReflection * fresnelFactor;引入菲涅尔后反射效果会随着视角变化而动态变化真实感大幅提升。这是区分普通效果和高级效果的关键一步。实操心得对于移动平台texCUBE采样和菲涅尔计算pow都是性能开销。一个常见的优化是预先计算菲涅尔值并存储在一张一维的Ramp贴图中在片段着色器里用dot(viewDir, normal)作为UV的x去采样用一次纹理采样替代复杂的pow计算。纹理采样在GPU上往往比复杂数学运算更快。5. 性能优化与移动端适配实战一个全功能的镜面反射Shader在PC上跑可能没问题但直接用到手机项目里很可能成为性能瓶颈。我们需要做一系列优化。5.1 简化计算与近似在顶点着色器计算将worldNormal和viewDir的计算移到顶点着色器。虽然片段着色器仍需归一化但计算量已减少。简化菲涅尔使用更简单的公式如fresnel 1.0 - dot(viewDir, normal)甚至直接用一个恒定的反射强度在美术效果可接受的前提下换取性能。降低纹理采样精度对于Cubemap使用压缩格式如ETC2 ASTC并降低其分辨率。128x128或64x64的Cubemap在移动端很多时候已经足够。避免动态分支Shader中的if语句在GPU上效率很低尽量避免。例如可以用step()或saturate()函数来替代简单的条件判断。5.2 使用Unity内置的简化光照函数Unity提供了一些内置的简化光照函数它们已经为移动端优化过。例如我们可以使用ShadeSH9来获取球谐光照一种低代价的环境光近似以及使用UNITY_LIGHTMODEL_AMBIENT。对于更复杂的需求可以考虑使用Surface Shader它让Unity帮你生成前向渲染所需的多Pass代码但你仍然可以编写自己的光照函数。不过对于极致的移动端优化手写一个精简的顶点/片段着色器通常更可控。5.3 针对不同材质类型的Shader变体不是所有物体都需要完整的镜面反射。我们可以创建多个简化版本的Shader。高性能版无反射只保留漫反射和环境光用于远处物体或背景。中性能版Blinn-Phong高光包含本文实现的基础Blinn-Phong高光但不采样Cubemap。适用于大多数需要高光的物体。高效果版完整反射包含Cubemap反射和菲涅尔效应仅用于关键的、近处的金属或水面物体。在Unity中可以通过Shader的LODLevel of Detail功能或直接编写多个Shader根据摄像机距离或平台动态切换。5.4 实战代码一个移动端友好的简化版这里给出一个极度简化的移动端镜面反射Shader框架去掉了菲涅尔和Cubemap只保留核心的Blinn-Phong高光并做了优化。Shader Mobile/MobileSpecular { Properties { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) _Specular (Specular, Color) (0.5, 0.5, 0.5, 1) _Shininess (Shininess, Range(2, 64)) 16 // 移动端用较小的值 } SubShader { Tags { RenderTypeOpaque LightModeForwardBase } LOD 150 // 设置较低的LOD值在低端设备上可能被更简单的Shader替代 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 2.0 // 针对较低Shader模型 #include UnityCG.cginc #include Lighting.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; half3 worldNormal : TEXCOORD1; half3 viewDir : TEXCOORD2; half3 lightDir : TEXCOORD3; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color, _Specular; half _Shininess; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.viewDir normalize(UnityWorldSpaceViewDir(worldPos)); // 使用内置函数 o.lightDir normalize(UnityWorldSpaceLightDir(worldPos)); // 使用内置函数 return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; half3 worldNormal normalize(i.worldNormal); half3 viewDir normalize(i.viewDir); // 仍需归一化 half3 lightDir normalize(i.lightDir); // 漫反射 half ndotl saturate(dot(worldNormal, lightDir)); fixed3 diffuse _LightColor0.rgb * ndotl * col.rgb; // 镜面反射 (Blinn-Phong) half3 halfDir normalize(lightDir viewDir); half ndoth saturate(dot(worldNormal, halfDir)); half spec pow(ndoth, _Shininess); fixed3 specular _LightColor0.rgb * _Specular.rgb * spec; // 环境光 (简化) fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * col.rgb; fixed3 finalColor ambient diffuse specular; return fixed4(finalColor, col.a); } ENDCG } } FallBack Mobile/VertexLit }这个Shader使用了UnityWorldSpaceViewDir和UnityWorldSpaceLightDir内置函数来简化计算将向量计算全部放在顶点着色器并设置了较低的#pragma target和LOD非常适合移动端。6. 常见问题与调试技巧实录即使按照教程写完了Shader效果不对也是家常便饭。这里记录几个我踩过的坑和调试方法。6.1 问题排查清单问题现象可能原因排查步骤完全没有高光/反射1. 场景中没有激活的光源。2. 法线向量计算错误最常见。3._LightColor0为黑色。4. Shader编译错误但Unity未报错检查Console。1. 确保场景有Directional Light或其它光源。2. 在片段着色器输出worldNormal或dot(N, L)的值到颜色通道可视化检查。法线应显示为平滑的彩色渐变。3. 检查光源的Color和Intensity。4. 在Shader Inspector面板查看编译状态和错误信息。高光位置错误或闪烁1. 视线方向viewDir或光线方向lightDir计算错误符号反了。2. 向量在传递后没有在片段着色器重新归一化。3. 模型法线本身有问题导入设置错误。1. 牢记viewDir normalize(相机位置 - 表面位置)方向指向相机。lightDir对于平行光是_WorldSpaceLightPos0.xyz。2.务必在片段着色器对worldNormal,viewDir,lightDir进行normalize。3. 在模型导入设置中检查“Normals”选项是否为“Calculate”或“Import”。高光边缘有锯齿或断点高光指数_Gloss太大导致pow计算在低精度下如移动端出现精度问题。1. 降低_Gloss值移动端建议不超过64。2. 在pow计算前给点积结果加一个极小偏移pow(max(1e-4, ndoth), _Gloss)。Cubemap反射错乱或拉伸1. 反射方向reflectDir计算错误。2. Cubemap纹理本身不是真正的立方体贴图。3. 模型的UV或法线信息有误。1. 确认reflect(-viewDir, normal)计算正确。可以输出reflectDir到颜色可视化。2. 确保导入的Cubemap资源类型是“Cubemap”而不是“Texture”。3. 尝试用一个简单的Sphere模型测试。在移动端帧率暴跌1. 在片段着色器进行了过多复杂计算如多个pow,normalize。2. 使用了高分辨率Cubemap。3. Shader变体过多导致Draw Call增加。1. 遵循第5节的优化建议将计算上移到顶点着色器简化公式。2. 将Cubemap分辨率降至128或64。3. 使用Shader LOD或为移动端单独编写简化版Shader。6.2 调试技巧可视化中间变量这是Shader调试最有效的手段。将你怀疑有问题的中间变量如法线、点积结果、反射方向直接作为颜色输出。例如在片段着色器中临时替换返回语句// 可视化法线将-1到1映射到0到1 return fixed4(worldNormal * 0.5 0.5, 1.0); // 可视化兰伯特系数 return fixed4(lambert.xxx, 1.0); // 可视化视线方向 return fixed4(viewDir * 0.5 0.5, 1.0);通过观察颜色你可以直观地判断向量的方向是否正确计算的值是否在预期范围内。法线应该是一个平滑变化的彩色球如果出现大片纯色或奇怪的图案计算肯定出错了。6.3 关于性能分析使用Unity的Frame Debugger和Profiler。Frame Debugger可以逐帧查看每个Draw Call的状态确认你的Shader是否被正确调用以及渲染状态如混合模式、深度测试是否正确。Profiler在GPU模块中可以查看每个Shader的耗时。重点关注GPU time和SetPass calls。如果你的自定义Shader耗时明显高于Standard Shader就需要回头审视优化点了。写Shader是一个不断迭代和调试的过程。从最简单的模型开始每增加一个功能就测试一次效果和性能逐步构建出复杂的效果这才是最稳妥的路径。别指望一口气写出一个完美无缺的Shader尤其是在面对不同平台和性能约束的时候。