Unity镜面反射Shader实现:从Phong到PBR与SSR进阶指南

发布时间:2026/8/2 12:56:35
Unity镜面反射Shader实现:从Phong到PBR与SSR进阶指南 1. 项目概述从“看起来像”到“感觉对”的质感飞跃在Unity3D里鼓捣过一阵子3D模型的朋友估计都经历过这么一个阶段模型导进来了贴图也贴上了乍一看有模有样但总觉得哪里不对劲——它看起来“平”缺乏那种真实世界里物体该有的“质感”。这种质感缺失很大程度上就源于对光线反射的模拟不足。一个金属水龙头、一个光滑的陶瓷花瓶或者一个平静的湖面它们之所以能被我们一眼识别核心就在于其表面能清晰地映照出周围的环境。这就是镜面反射的魅力它不是简单的颜色叠加而是光线与表面微观结构相互作用后遵循物理定律入射角等于反射角形成的、具有方向性的高光区域。实现一个高质量的镜面反射Shader远不止是在物体表面贴一张环境贴图那么简单。它涉及到对光线、视角、表面法线、粗糙度等一系列物理属性的精确计算。网上能找到的代码片段很多但往往只告诉你“这么写能亮”却不解释“为什么这么写才对”。结果就是稍微改个参数或者换个场景效果就崩了排查起来一头雾水。这篇指南我想从一个实际开发者的角度跟你聊聊怎么从零开始理解并亲手实现一个物理上相对正确、效果可控、性能也还过得去的镜面反射Shader。我们会从最基础的Phong光照模型聊起过渡到现在更主流的Blinn-Phong和基于物理的渲染PBR中的镜面反射项最后再探讨如何用屏幕空间反射SSR这类“黑科技”来实现动态的、逼真的镜面倒影。目标是让你不仅能“抄”出一段能跑的代码更能明白每一行代码背后的意图做到举一反三真正把镜面反射这个工具握在手里。2. 核心原理与光照模型演进在动手写代码之前我们必须先搞清楚要计算什么。镜面反射的计算核心是回答一个问题从观察者摄像机的位置看去有多少来自光源的光线恰好被表面反射到了眼睛里不同的光照模型给出了不同的近似答案。2.1 经典起点Phong光照模型Phong模型是最直观的入门选择。它的镜面反射计算基于一个非常形象的几何概念反射向量R。计算反射向量 (R)给定入射光方向L从表面点指向光源和表面法线N根据物理定律反射向量R可以通过公式R reflect(-L, N)计算。reflect函数是很多Shader语言的内置函数其内部计算是R 2 * dot(N, L) * N - L。计算镜面反射强度然后我们计算观察方向V从表面点指向摄像机与反射向量R的夹角。直觉上当V和R完全重合时我们应该看到最亮的镜面高光随着V偏离R高光强度应该迅速衰减。Phong模型用这两个向量的点积的幂次方来模拟这种衰减Specular pow(max(dot(V, R), 0), shininess)这里的shininess被称为高光指数它是一个非常重要的参数。值越大比如128、256高光点就越集中、越小、越锐利模拟的是非常光滑的表面如抛光金属。值越小比如8、16高光就越分散、越柔和模拟的是相对粗糙的表面如哑光塑料。注意Phong模型计算反射向量R需要用到reflect函数或手动计算这在早期的固定管线时代有一定开销。而且当观察方向V与反射方向R的夹角大于90度时点积为负我们通过max(dot(V, R), 0)将其截断为0这是合理的因为不可能有“负”的光强。2.2 优化与改进Blinn-Phong光照模型Blinn-Phong是对Phong模型的一个经典优化也是Unity内置的Standard Shader在非PBR模式下的默认选择。它提出了一个更巧妙的中间向量H半角向量。计算半角向量 (H)半角向量是光线方向L和观察方向V的角平分线方向计算非常简单H normalize(L V)。计算镜面反射强度Blinn-Phong不再比较V和R而是比较表面法线N和半角向量HSpecular pow(max(dot(N, H), 0), shininess)为什么Blinn-Phong更常用从计算上看求H比求R更简单少一次反射计算。更重要的是在实践上使用相同的高光指数时Blinn-Phong产生的高光区域通常比Phong模型更“大”一些。为了得到视觉效果相近的高光Blinn-Phong所需的shininess值大约是Phong模型的2到4倍。虽然数学模型不同但Blinn-Phong在视觉上足够好且计算效率略高因此成为了更普遍的选择。2.3 现代标准基于物理的渲染PBR与Cook-Torrance模型当项目对质感要求更高时Phong和Blinn-Phong的局限性就显现了它们的高光形状和强度变化不够真实参数如shininess缺乏直接的物理意义比如256的“光滑度”到底对应多粗糙的表面。基于物理的渲染PBR采用了一套更严谨的模型其中镜面反射部分通常使用Cook-Torrance或GGX模型。它不再用一个简单的幂函数而是将镜面反射拆解为几个具有物理意义的因子Specular (F * D * G) / (4 * dot(N, V) * dot(N, L))D (Normal Distribution Function 法线分布函数)描述微观表面法线的分布决定了高光的大小和形状。GGX是现在最流行的NDF它能产生边缘拖尾的、更真实的高光。G (Geometry Function 几何函数)模拟微观表面的自遮挡阴影在表面非常粗糙时会削弱掠射角下的高光。F (Fresnel Equation 菲涅尔方程)描述反射强度随观察角度变化的规律。核心规律是视线与表面夹角越大掠射角反射越强。这就是为什么你看向池塘远处的水面感觉它像镜子一样反光而看脚边的水面却能看清水底。在Unity的Standard或URP/HDRP的Lit Shader中我们通常通过两个直观的参数来控制Metallic金属度接近1时表面更像导体如金属漫反射几乎为0颜色信息全部由镜面反射菲涅尔贡献。Smoothness光滑度接近1时表面微观几何更平滑对应更锐利的高光D项更集中和更少的微表面遮挡G项影响更小。实操心得对于大多数项目除非是风格化渲染或有极致的性能要求否则我强烈建议直接使用Unity内置的URP/Lit或HDRP/Lit Shader作为起点。它们已经实现了完整的PBR管线你只需要在材质面板上调整Metallic和Smoothness滑块就能得到物理上可信的镜面反射效果。自己从头实现一个完整的Cook-Torrance模型作为学习是极好的但在生产环境中重复造轮子的性价比很低。3. 动手实现一个完整的Blinn-Phong镜面反射Shader理解了原理我们来实现一个包含漫反射和镜面反射的完整Unlit Shader不依赖Unity内置光照管线便于理解。我们将实现Blinn-Phong模型。3.1 Shader结构与属性定义首先我们创建Shader的基本框架并定义需要在材质面板上调整的属性。Shader Custom/SpecularBlinnPhong { Properties { _MainTex (Texture, 2D) white {} _MainColor (Color, Color) (1,1,1,1) _SpecularColor (Specular Color, Color) (1,1,1,1) _Shininess (Shininess, Range(1.0, 256.0)) 32.0 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc // 为了获取光源信息 // 从Properties传递到CG代码 sampler2D _MainTex; float4 _MainTex_ST; // 用于纹理缩放偏移 fixed4 _MainColor; fixed4 _SpecularColor; float _Shininess; // 顶点着色器的输入结构 struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; // 顶点着色器输出到片段着色器的结构 struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; };代码解析_SpecularColor镜面反射的颜色。对于非金属通常设为白色或光源色。对于金属它可以带有颜色如金子的黄色高光。_Shininess高光指数范围1到256通过Range属性限制在合理范围。我们包含了Lighting.cginc以便使用_LightColor0和_WorldSpaceLightPos0来获取场景主平行光的信息。v2f结构体中我们将世界空间下的法线(worldNormal)和顶点位置(worldPos)传递给片元着色器这是进行光照计算所必需的。3.2 顶点着色器坐标变换与数据准备顶点着色器的主要任务是将顶点从模型空间变换到裁剪空间并计算世界空间下的法线和位置。v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // 模型空间-裁剪空间 o.uv TRANSFORM_TEX(v.uv, _MainTex); // 应用纹理缩放偏移 // 将法线从模型空间变换到世界空间并归一化 o.worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 将顶点位置从模型空间变换到世界空间 o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; }关键点法线变换需要使用模型变换矩阵的逆转置矩阵unity_WorldToObject这里用了其3x3部分并进行了转置操作mul(v.normal, (float3x3)unity_WorldToObject)来保证在模型存在非均匀缩放时法线方向依然正确。normalize是必须的因为变换后的法线长度可能不为1。3.3 片元着色器光照计算核心这里是实现Blinn-Phong镜面反射的核心。fixed4 frag (v2f i) : SV_Target { // 采样基础纹理和颜色 fixed4 col tex2D(_MainTex, i.uv) * _MainColor; // 准备向量全部归一化 float3 worldNormal normalize(i.worldNormal); float3 worldLightDir normalize(_WorldSpaceLightPos0.xyz); // 平行光方向 float3 worldViewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 观察方向 // 1. 计算漫反射 (Lambert) float ndotl max(0, dot(worldNormal, worldLightDir)); fixed3 diffuse _LightColor0.rgb * ndotl * col.rgb; // 光源颜色 * 衰减 * 表面色 // 2. 计算镜面反射 (Blinn-Phong) // 计算半角向量 float3 halfDir normalize(worldLightDir worldViewDir); // 计算法线与半角向量的点积 float ndoth max(0, dot(worldNormal, halfDir)); // 应用高光指数 float specularIntensity pow(ndoth, _Shininess); // 合成镜面反射颜色 fixed3 specular _LightColor0.rgb * _SpecularColor.rgb * specularIntensity; // 3. 环境光 (简化处理) fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * col.rgb; // 4. 最终颜色合成环境光 漫反射 镜面反射 fixed4 finalColor fixed4(ambient diffuse specular, col.a); return finalColor; } ENDCG } } FallBack Diffuse // 失败时回退到漫反射Shader }逐行解析与注意事项向量归一化normalize是光照计算中最容易出错也最关键的步骤之一。世界空间下的法线、光线方向、视线方向在计算点积前必须进行归一化否则点积结果将失去几何意义导致光照错误。这是一个高频错误点。漫反射计算ndotl max(0, dot(N, L))。max(0, ...)确保了当光线从背面照射时点积为负漫反射贡献为0这是符合物理的。半角向量计算halfDir normalize(worldLightDir worldViewDir)。这就是Blinn-Phong的核心比计算反射向量更高效。镜面反射计算ndoth max(0, dot(N, H))同样需要截断到0。pow(ndoth, _Shininess)幂运算pow是一个比较耗时的操作在早期的移动端Shader中需要谨慎使用。_Shininess值越大高光越锐利。最终镜光颜色由光源颜色、用户定义的镜面颜色和高光强度三者相乘得到。环境光UNITY_LIGHTMODEL_AMBIENT是Unity预定义的环境光颜色。这是一个非常简化的全局光照替代品。在复杂场景中你需要考虑光照探头、反射探头等来获得真实的环境反射。颜色合成我们简单地将环境光、漫反射、镜面反射相加。在真正的PBR模型中能量守恒要求漫反射和镜面反射的和不能超过1我们这里简化处理了。将这个Shader赋给一个材质并拖到场景中的一个球体上。调整_Shininess你会看到高光点的大小和锐利度发生明显变化。调整_SpecularColor可以改变高光的色调。4. 进阶实现基于屏幕空间的后处理镜面反射SSR上面实现的Shader是局部反射它只考虑单一光源反射的内容是光源本身一个亮斑。而现实中镜面表面反射的是整个周围环境。为了实现这个传统方法是使用立方体贴图Cubemap但这只能反射静态环境。对于动态物体比如一个走过镜面的角色就需要更高级的技术比如屏幕空间反射Screen Space Reflection SSR。SSR是一种后处理效果其核心思想非常“暴力”且巧妙对于屏幕上的每一个像素我沿着它的反射方向在当前帧已经渲染好的屏幕深度和颜色缓冲区里“步进”查找看它能否击中另一个表面。如果击中了就用击中点的颜色作为反射颜色。4.1 SSR核心算法步骤我们可以在Unity中通过编写一个Image Effect脚本来实现一个简化的SSR。获取必要数据在OnRenderImage函数中通过CameraDepthTextureMode.Depth获取深度纹理并通过Shader.GlobalTexture传递当前渲染的屏幕图像_CameraGBufferTexture0或_MainTex。计算反射向量在Shader中我们需要重建每个像素在世界空间中的位置和法线。重建世界位置使用深度纹理值和当前像素的屏幕UV结合摄像机的投影矩阵逆矩阵可以反算出该像素在世界空间中的坐标。获取世界法线如果使用延迟渲染法线信息在GBuffer中。如果使用前向渲染可能需要额外渲染一张法线纹理或者使用一个简化版本例如用深度差来近似计算。屏幕空间光线步进Raymarching从当前像素的世界位置出发沿着计算出的反射向量方向发射一条射线。将射线分割成若干步每一步都计算一个采样点。将采样点的世界坐标转换回屏幕空间UV。对比采样点的深度通过屏幕UV从深度纹理中采样得到与射线在当前步进深度下的预期深度。如果采样点的深度比预期深度更“近”即射线击中了某个表面则命中。用命中点的屏幕颜色作为反射颜色。如果步进超过最大步数或超出屏幕范围则未命中可能回退到使用立方体贴图或直接忽略反射。处理命中与未命中命中后可以对采样颜色进行模糊、混合等处理以模拟不同粗糙度表面的反射模糊效果。未命中时需要有一个优雅的回退方案。4.2 简化版SSR Shader代码框架下面是一个极度简化的概念性代码用于说明SSR Shader的核心结构Shader Hidden/SimpleSSR { Properties {...} SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert_img // 使用内置的全屏四边形顶点着色器 #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; // 当前帧颜色 sampler2D _CameraDepthTexture; // 深度纹理 float4x4 _InverseViewProjectionMatrix; // 从屏幕到世界的变换矩阵 // 函数用深度和UV重建世界位置 float3 ReconstructWorldPos(float2 uv, float depth) { float4 clipPos float4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); float4 worldPos mul(_InverseViewProjectionMatrix, clipPos); return worldPos.xyz / worldPos.w; } fixed4 frag (v2f_img i) : SV_Target { fixed4 originalColor tex2D(_MainTex, i.uv); float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); depth Linear01Depth(depth); // 转换为线性深度 // 1. 重建当前像素的世界位置和法线此处简化假设法线已提供 float3 worldPos ReconstructWorldPos(i.uv, depth); float3 worldNormal tex2D(_NormalTex, i.uv).xyz * 2.0 - 1.0; // 假设有法线图 // 2. 计算反射方向 (R) float3 worldViewDir normalize(_WorldSpaceCameraPos - worldPos); float3 reflectDir reflect(-worldViewDir, worldNormal); // 3. 简单的光线步进 float3 rayPos worldPos; fixed4 reflectedColor fixed4(0,0,0,0); bool hit false; for (int step 0; step _MaxSteps; step) { rayPos reflectDir * _StepSize; // 将rayPos投影回屏幕空间 float4 screenPos mul(UNITY_MATRIX_VP, float4(rayPos, 1.0)); screenPos.xyz / screenPos.w; float2 screenUV screenPos.xy * 0.5 0.5; // 检查是否在屏幕内 if (any(screenUV 0) || any(screenUV 1)) break; // 获取采样点的深度 float sampleDepth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenUV); sampleDepth LinearEyeDepth(sampleDepth); // 转换为眼空间深度 float rayDepth screenPos.z; // 当前射线点的深度 // 深度比较如果射线点比场景表面“更远”则认为击中 if (rayDepth sampleDepth _DepthBias) { reflectedColor tex2D(_MainTex, screenUV); hit true; break; } } // 4. 混合结果 if (hit) { return lerp(originalColor, reflectedColor, _ReflectionStrength); } else { // 未命中可以采样Cubemap或返回原色 return originalColor; } } ENDCG } } }4.3 SSR的优缺点与性能考量优点高度真实能反射场景中所有可见的动态和静态物体包括其他角色、粒子、动态光照等。无需预计算不像立方体贴图需要预先烘焙。缺点与挑战性能开销大每个像素都要进行多次步进和深度采样是GPU密集型操作。屏幕空间局限性只能反射当前屏幕上可见的内容。反射射线如果指向屏幕外比如反射天花板但天花板不在画面里或者被前景物体遮挡就会失效导致反射信息缺失。实现复杂需要处理大量的边缘情况如深度不连续、软硬件差异、抗锯齿等。走样与噪点步进采样容易产生噪点通常需要结合时域累积TAA或空间滤波来平滑。实操心得SSR是一种“看起来很美”的技术但在移动端或性能敏感的项目中要慎用。通常的折中方案是对关键的、大面积的镜面物体如地面、水池使用SSR并结合粗糙度控制反射模糊对于小物体或性能不足的平台回退到使用高质量的预烘焙立方体贴图或平面反射探头Reflection Probe。Unity的URP/HDRP内置了SSR组件通常比自己从头实现要稳定和高效建议优先使用。5. 常见问题、优化技巧与调试方法即使理解了原理在实现和优化镜面反射时你依然会遇到各种“坑”。这里分享一些实战中积累的经验。5.1 高频问题排查清单问题现象可能原因排查与解决思路高光区域出现锯齿或闪烁1._Shininess值过高导致高光区域太小在像素级别变化剧烈。2. 法线贴图精度不足或Mipmap问题。3. 没有使用抗锯齿MSAA/TAA。1. 适当降低_Shininess或使用更平滑的分布函数如GGX。2. 确保法线贴图导入设置正确启用Mipmap。3. 在质量设置中开启抗锯齿。对于移动端可考虑在Shader中对高光计算结果进行简单的模糊。镜面反射颜色过曝或发白1. 光源强度(_LightColor0)过强。2. 镜面颜色(_SpecularColor)或强度叠加过多。3. 没有进行色调映射HDR场景。1. 检查场景光源强度确保在合理范围如0-1。2. 遵循能量守恒原则漫反射镜面反射总和不应明显超过1。在PBR流程中金属度高的物体漫反射应为黑色。3. 在HDR管线中确保最后有正确的色调映射Tonemapping步骤。物体边缘掠射角反射异常弱或强菲涅尔效应计算错误或未启用。检查菲涅尔项计算。在Blinn-Phong等简单模型中可以近似加入菲涅尔fresnel _FresnelScale (1 - _FresnelScale) * pow(1 - dot(V, N), 5)并用它来增强掠射角反射。移动设备上Shader变卡1. 在片元着色器中进行了过多的复杂计算如pow,sin,cos。2. 使用了高精度的浮点数(float)而非半精度(half或fixed)。3. 纹理采样次数过多。1. 将能移到顶点着色器的计算如世界坐标、法线变换尽量上移。2. 在移动端对颜色、强度等数据使用fixed或half精度。3. 合并纹理减少采样次数。考虑使用查找表LUT来替代实时计算。自定义Shader与场景其他物体光照不协调光照计算模型不一致如场景用PBR你的Shader用Blinn-Phong。尽量使用与项目渲染管线一致的光照模型。如果必须混用需仔细调整参数使视觉风格匹配。最好的方式是直接基于URP/HDRP的Lit Shader模板进行修改。SSR反射出现断裂或扭曲1. 深度缓冲精度不足特别是在远距离。2. 步进(_StepSize)过大跳过了相交点。3. 深度偏置(_DepthBias)设置不当。1. 使用线性深度或反向深度Reversed-Z提高精度。2. 减小步进长度或采用自适应步进、二分查找等更高级的步进策略。3. 仔细调整_DepthBias以消除因深度精度问题导致的错误相交或漏交。5.2 关键优化技巧精度选择在片元着色器中对颜色使用fixed低精度对方向和坐标使用half中精度仅在必要时使用float高精度。这能显著提升移动端的性能。计算转移像normalize(i.worldNormal)这种每个像素都进行的计算如果模型顶点数不多可以考虑在顶点着色器中计算并插值虽然会损失一些精度但能节省大量计算。善用内置函数和变量Unity提供了大量优化过的内置函数如UnityWorldSpaceViewDir,UnityWorldSpaceLightDir和变量如_WorldSpaceCameraPos它们通常比自己手动计算更高效、更准确。简化模型在移动端或对大量物体渲染时考虑使用更简化的光照模型。例如可以将镜面反射计算简化为基于视角和法线的“假高光”Fake Specular或者使用一张预计算的高光遮罩贴图。静态反射烘焙对于静态环境毫不犹豫地使用烘焙的反射探头Reflection Probe。这是性能最好、效果最稳定的方案。将动态物体排除在反射探头的烘焙对象之外可以避免错误反射。5.3 调试眼见为实Shader调试不像普通代码可以设断点主要靠“可视化”将中间变量输出为颜色这是最有效的调试手段。如果你不确定法线是否正确可以在片元着色器中直接return float4(worldNormal * 0.5 0.5, 1.0);将法线可视化从(-1,-1,-1)映射到(0,0,0)的黑色到(1,1,1)的白色。同样可以可视化视线方向、半角向量、点积结果等。使用Frame DebuggerUnity的Frame Debugger可以一步步查看Draw Call和渲染状态确认你的Shader是否正确执行以及输入数据是否正确绑定。简化测试场景在一个只有纯色背景、一个平行光和一个测试物体的简单场景中调试排除其他干扰因素。实现一个效果出色的镜面反射是一个在物理准确性、视觉美感、运行性能三者之间不断权衡和迭代的过程。从理解Phong/Blinn-Phong的基本原理开始到能够驾驭PBR的参数再到有能力评估和集成SSR这样的高级特性每一步都需要动手实践和耐心调试。最关键的体会是不要孤立地看待Shader代码而要时刻将屏幕上的像素与你所理解的物理世界中的光线行为联系起来。当你看到自己调整的几个参数让一个虚拟的物体突然拥有了真实的光泽和质感时那种成就感正是图形编程最大的乐趣所在。