1. 项目概述从“看得见”到“看得懂”的光照模型在图形渲染的世界里我们写的每一行Shader代码本质上都是在回答一个问题这个像素点在当前的灯光和视角下应该是什么颜色这听起来简单但背后是一整套模拟光线与物体表面交互的物理和数学模型。对于刚接触Shader编程的朋友来说漫反射和高光反射就是打开这扇门的钥匙。它们不是Unity、Cocos Creator或者Unreal Engine的专属而是计算机图形学中描述物体表面基础光照特性的两个核心光照模型。漫反射模拟的是光线在粗糙表面上的均匀散射。想象一下一张粗糙的A4纸无论你从哪个角度看它的亮度都差不多这就是漫反射的效果。它决定了物体表面的基础颜色和明暗关系是物体能被我们“看见”的基础。而高光反射模拟的则是光线在光滑表面上的镜面反射。就像你看到的不锈钢水杯上那个亮晶晶的光斑它会随着你的视角移动而移动这个光斑就是高光。它赋予了物体“光泽感”和“质感”是区分塑料、金属、陶瓷等不同材质的关键。网上有海量的Shader案例和代码片段但很多时候我们只是“复制-粘贴”看到了效果却不明白为什么这几行代码就能产生这样的效果。这个项目的目的就是带你亲手实现这两个基础光照模型并彻底搞懂背后的数学原理和实现细节。我们会从最基础的Lambert漫反射模型和Phong高光模型开始一步步推导公式、编写代码并探讨它们在实际项目中的应用与优化。无论你是想为你的游戏角色增添真实的皮革质感还是想让UI界面产生精致的金属反光理解这些基础都是必经之路。2. 核心光照模型原理深度拆解要动手写Shader不能只停留在“效果”层面必须深入理解光照模型背后的物理假设和数学表达。这就像学做菜不能只看菜谱得明白为什么加热、为什么调味。2.1 漫反射Lambert模型的来龙去脉漫反射的核心思想是光线到达物体表面后向各个方向均匀地散射出去。这意味着从任何方向观察该点的亮度都是相同的。但亮度与什么有关呢与光线照射到表面的角度有关。这里引入一个关键概念表面法线。它是垂直于物体表面某一点的一个单位向量。光线方向与法线方向的夹角决定了光线照射的“正”与“斜”。Lambert定律用余弦函数来描述这种关系表面接收到的光强与光线方向向量和表面法线向量夹角的余弦值成正比。夹角为0度光线垂直照射时余弦值为1光强最大夹角为90度光线擦过表面时余弦值为0光强为0。在Shader中我们通常处理的是单位向量。设光源方向为L指向光源表面法线为N那么漫反射光强系数就是dot(N, L)的点积结果。但这里有个细节点积结果可能是负数当夹角大于90度意味着光线来自表面背面而光强不能为负。所以实际计算时我们需要使用max(dot(N, L), 0)来钳制结果。最终漫反射颜色计算公式为漫反射颜色 光源颜色 * 表面漫反射颜色 * max(dot(N, L), 0)注意这里的L在计算时通常需要从世界空间或模型空间转换到合适的坐标系如切线空间并与同样在该空间下的法线N进行计算确保两者在同一个坐标空间下这是新手最容易出错的地方之一。2.2 高光反射Phong与Blinn-Phong的抉择高光反射模拟的是镜面反射。我们能看到高光是因为眼睛摄像机恰好位于光线经表面反射后的反射光方向上。Phong模型直接模拟了这一物理过程。它需要三个向量视线方向V从表面点指向摄像机光线方向L法线方向N。首先根据N和L计算出反射光方向R。然后计算视线方向V与反射方向R的夹角。夹角越小说明视线越接近完美的反射方向看到的高光就应该越强。同样用点积的余弦值dot(V, R)来衡量并取其最大值。但Phong模型发现现实中的高光衰减并非线性的。夹角增大时高光强度会急剧下降。因此引入了一个光泽度参数通常称为Gloss或Shininess。通过一个指数函数来模拟这种衰减pow(max(dot(V, R), 0), gloss)。gloss值越大高光点越集中、越锐利材质看起来越像光滑的金属或瓷器值越小高光越分散、越柔和类似塑料或布料。Phong模型物理意义明确但计算反射向量R需要用到reflect函数有一定开销。Blinn-Phong模型提出了一种优化方案。它不计算反射向量R而是引入一个半角向量H。H是光线方向L和视线方向V的角平分线方向归一化后的L V。然后通过计算法线N与半角向量H的夹角来模拟高光公式为pow(max(dot(N, H), 0), gloss)。Blinn-Phong模型在计算上更高效少了一次反射计算并且在视觉效果上当gloss值较大时其高光范围比Phong模型更“宽”一些有时被认为更符合某些真实材质的观感。因此在实时渲染中Blinn-Phong的应用更为广泛。最终高光颜色计算公式为以Blinn-Phong为例高光颜色 光源颜色 * 高光反射颜色 * pow(max(dot(N, H), 0), gloss)2.3 环境光不可或缺的补光者一个只有漫反射和高光的物体在背对光源的一面会完全漆黑这不符合我们对现实世界的认知。因为光线会在场景中多次反弹。为了简化计算我们引入一个常量——环境光。它假设所有物体都受到一个均匀的基础光照无论其朝向如何。环境光的计算最简单环境光颜色 全局环境光颜色 * 表面环境光颜色在最终的片元着色器中我们将环境光、漫反射光和高光反射光的效果叠加起来就得到了该像素点的最终颜色。这就是经典的前向渲染逐像素光照的基础框架。3. 在Unity URP中实现基础光照Shader理论需要实践来验证。我们选择在Unity的通用渲染管线中实现因为URP是当前Unity开发的主流其Shader编写结构与Built-in管线有差异更需要清晰的指引。我们将创建一个完整的、包含漫反射和高光的Shader。3.1 项目准备与Shader框架搭建首先在Unity中创建一个URP项目。在项目窗口中右键选择Create - Shader - Universal Render Pipeline - Lit Shader并命名为SimpleSpecular。Unity会为我们生成一个基于PBR的复杂Shader模板但我们为了学习需要从一个更简单的无光照Shader模板开始修改。更清晰的做法是直接创建新的Shader文件。我们可以创建一个文本文件将后缀改为.shader然后从头编写。一个URP Shader的基本结构如下Shader Custom/SimpleDiffuseSpecular { Properties { // 属性块定义在材质面板中可调节的参数 _BaseColor (Base Color, Color) (1,1,1,1) _BaseMap (Base Map, 2D) white {} _Gloss (Gloss, Range(8.0, 256)) 20 _SpecularColor (Specular Color, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Name ForwardLit Tags { LightModeUniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag // 包含URP的核心库文件 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // 定义与Properties块对应的变量 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); float4 _BaseMap_ST; // 用于纹理的缩放和偏移 half4 _BaseColor; half _Gloss; half4 _SpecularColor; // 定义顶点着色器的输入结构 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; // 定义顶点着色器输出到片元着色器的结构 struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; }; // 顶点着色器 Varyings vert(Attributes IN) { Varyings OUT; // 将顶点位置从物体空间转换到齐次裁剪空间 VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; // 将法线从物体空间转换到世界空间 VertexNormalInputs normalInputs GetVertexNormalInputs(IN.normalOS); OUT.normalWS normalInputs.normalWS; // 计算世界空间顶点位置 OUT.positionWS positionInputs.positionWS; // 处理纹理UV支持材质面板的Tiling和Offset OUT.uv TRANSFORM_TEX(IN.uv, _BaseMap); return OUT; } // 片元着色器 half4 frag(Varyings IN) : SV_Target { // 后续的光照计算将在这里进行 return half4(1,1,1,1); } ENDHLSL } } }这个框架包含了URP Shader的必要元素属性定义、SubShader和Pass标签、HLSL代码块、以及顶点和片元着色器的基本结构。GetVertexPositionInputs和GetVertexNormalInputs是URP提供的工具函数帮我们处理了空间转换的复杂细节。3.2 逐像素光照计算实现现在我们在片元着色器frag函数中实现光照计算。URP提供了GetMainLight函数来获取场景中的主光源信息这比我们自己管理光源方便得多。half4 frag(Varyings IN) : SV_Target { // 采样纹理颜色 half4 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 获取主光源数据 Light mainLight GetMainLight(); float3 lightColor mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation; float3 lightDir normalize(mainLight.direction); // 光源方向从表面指向光源 // 准备向量数据 float3 normalWS normalize(IN.normalWS); // 世界空间法线 float3 viewDirWS normalize(_WorldSpaceCameraPos - IN.positionWS); // 视线方向表面指向相机 // 1. 环境光 (简化处理使用URP中的环境球谐采样或一个常量) half3 ambient half3(unity_SHAr.w, unity_SHAg.w, unity_SHAb.w); // 简化环境光 // 更准确的做法half3 ambient SampleSH(normalWS); // 2. 漫反射计算 (Lambert) float NdotL max(dot(normalWS, lightDir), 0.0); half3 diffuse lightColor * albedo.rgb * NdotL; // 3. 高光反射计算 (Blinn-Phong) float3 halfDir normalize(lightDir viewDirWS); // 计算半角向量 float NdotH max(dot(normalWS, halfDir), 0.0); float specularTerm pow(NdotH, _Gloss); half3 specular lightColor * _SpecularColor.rgb * specularTerm; // 4. 最终颜色合成 half3 finalColor ambient * albedo.rgb diffuse specular; return half4(finalColor, albedo.a); }这段代码完成了核心的光照计算。有几个关键点需要注意向量归一化在计算点积前确保normalWS、lightDir、viewDirWS都是单位向量。虽然从顶点着色器插值出来的法线可能接近单位向量但为了精度在片元着色器中重新归一化是良好习惯。光源衰减mainLight.distanceAttenuation和mainLight.shadowAttenuation分别代表了光源的距离衰减和阴影衰减。将它们乘入lightColor能让光照效果更真实。环境光简化这里使用了unity_SHAr.w等内置变量来获取一个简化的环境色。对于更高质量的环境光应使用SampleSH(normalWS)函数来采样球谐光照数据这能提供基于法线方向的动态环境反射。实操心得在URP中_WorldSpaceCameraPos是内置变量表示世界空间下的摄像机位置。计算视线方向时一定是摄像机位置 - 表面点位置得到一个从表面指向摄像机的向量。这个顺序反了高光计算就会完全错误。3.3 材质创建与效果调试Shader编写完成后在项目窗口中右键该Shader文件选择Create - MaterialUnity会自动创建一个使用该Shader的材质球。将这个材质球拖拽到场景中的物体上。在场景中创建一个Sphere球体和一个Directional Light平行光。将新建的材质赋给球体。调整材质面板上的参数观察效果调整_BaseColor改变物体的基础色。调整_Gloss值观察高光范围的变化。值越小高光越散越柔和值越大高光越集中越锐利。调整_SpecularColor可以改变高光的颜色。金属的高光颜色通常接近光源色白色而非金属如塑料的高光颜色可能更接近其表面色。尝试旋转平行光观察球体上漫反射明暗和高光位置的变化。移动摄像机视角高光位置应随之移动这正是Blinn-Phong模型的特征。4. 进阶探讨常见问题、优化与扩展实现基础功能只是第一步。在实际项目中我们会遇到各种问题也需要考虑性能和效果的扩展。4.1 常见问题与排查技巧实录即使代码逻辑正确渲染结果也可能不如预期。下面是一个常见问题速查表问题现象可能原因排查步骤与解决方案物体全黑或颜色异常1. 法线数据错误。2. 光源方向计算错误。3. Shader编译错误未生效。1. 在片元着色器输出normalWS作为颜色查看return half4(IN.normalWS * 0.5 0.5, 1.0)。法线应呈现平滑的渐变。2. 输出lightDir或NdotL值查看。确保lightDir是从表面指向光源。3. 检查Unity控制台是否有Shader编译错误红色错误信息。高光位置不对或不动1. 视线方向viewDir计算错误。2. 半角向量halfDir计算错误。3. 所有向量未在同一坐标空间。1. 输出viewDir作为颜色移动相机观察颜色变化。2. 检查halfDir normalize(lightDir viewDir)确保是加法且已归一化。3.最易错点确认normalWS,lightDir,viewDir都在世界空间下计算。lightDir来自GetMainLight()已是世界空间。高光边缘有锯齿_Gloss值很大时pow函数计算的高光边缘很锐利在低分辨率下产生锯齿。1. 使用smoothstep或更平滑的函数替代pow进行边缘抗锯齿。2. 增加渲染分辨率或使用后期处理的抗锯齿如MSAA、FXAA。背光面有奇怪高光当NdotL 0时背光面我们虽然屏蔽了漫反射但高光计算仍在进行。在高光计算前加入判断if (NdotL 0.0) specularTerm 0.0;这是符合物理的因为背光面不应有直接光源产生的高光。性能开销大每像素都进行pow运算且_Gloss是变量无法被Shader编译器优化。对于移动平台可以考虑将高光计算移到顶点着色器然后在片元着色器插值效果较差。或者使用查找表来近似pow计算。踩坑记录我曾在一个项目中高光怎么调都不对最后发现是导入的FBX模型没有法线信息。Unity虽然会生成平滑法线但有时并不准确。对于自定义模型务必在3D建模软件中检查并导出正确的法线。4.2 从Phong到PBR的思维跨越我们实现的Blinn-Phong模型是一个经验模型它效果不错且计算快但物理准确性不足。现代游戏和3A大作普遍采用基于物理的渲染模型。PBR的核心在于能量守恒和微表面理论。它认为物体表面是由无数个微小的镜面组成的。粗糙度决定了这些微表面的朝向分布从而影响漫反射和高光的比例。PBR通常使用两个核心参数金属度表示材质是金属还是非金属。金属的漫反射很弱其颜色主要来自对环境的镜面反射即高光颜色与光源色强相关。非金属则有明显的漫反射。粗糙度取代了_Gloss但意义相反。粗糙度越高表面越粗糙高光越分散。在URP的Lit Shader中已经完整实现了PBR。理解了我们手写的Blinn-Phong之后再去学习PBR的BRDF方程就会明白那些复杂的项如法线分布函数D、几何遮蔽函数G、菲涅尔方程F各自在模拟什么物理现象学习曲线会平滑很多。4.3 在Shader Graph中可视化构建对于不习惯写代码的美术或技术美术同学Unity的Shader Graph和Amplify Shader Editor这类可视化工具是绝佳选择。它们背后的节点其实就是我们上面编写的代码片段。例如构建一个简单的Blinn-Phong光照通过Sample Texture 2D节点获取基础色。使用Normal Vector节点获取法线。使用Light Direction和View Direction节点获取光源和视线方向。用Dot Product节点计算NdotL用Add和Normalize节点计算半角向量H再用一个Dot Product计算NdotH。用Power节点实现pow(NdotH, Gloss)。最后用Multiply和Add节点将环境光、漫反射、高光组合起来。在Shader Graph中拖拽节点、连接连线的过程能非常直观地理解数据是如何流动和计算的是对代码学习极好的补充。你可以尝试用Shader Graph复现我们上面代码的效果感受可视化编程的逻辑。5. 多引擎下的实现异同与实战应用掌握了原理就可以在不同引擎和工具中游刃有余。思路是相通的只是API和语法不同。5.1 在Cocos Creator中的实现要点Cocos Creator的Shader编写类似于GLSL ES。其内置了一些有用的Uniform和函数。// Cocos Creator Shader 片段示例 (Effect写法) CCProgram fs %{ precision highp float; #include builtin/uniforms/cc-global #include builtin/uniforms/cc-light in vec3 v_normal; in vec3 v_position; in vec2 v_uv; uniform sampler2D mainTexture; uniform vec4 mainColor; uniform float gloss; uniform vec4 specularColor; vec4 frag () { vec4 albedo texture(mainTexture, v_uv) * mainColor; // 获取主平行光简化处理Cocos可能有多光源管理 // 这里假设cc_mainLitLight是引擎提供的主光数据 vec3 lightDir normalize(cc_mainLitLight.direction); vec3 lightColor cc_mainLitLight.color; vec3 normal normalize(v_normal); vec3 viewDir normalize(cc_cameraPos.xyz - v_position); // 环境光 (使用引擎内置的环境色) vec3 ambient cc_ambientLight.rgb * albedo.rgb; // 漫反射 float NdotL max(dot(normal, lightDir), 0.0); vec3 diffuse lightColor * albedo.rgb * NdotL; // 高光 (Blinn-Phong) vec3 halfDir normalize(lightDir viewDir); float NdotH max(dot(normal, halfDir), 0.0); float specularTerm pow(NdotH, gloss); vec3 specular lightColor * specularColor.rgb * specularTerm; vec3 finalColor ambient diffuse specular; return vec4(finalColor, albedo.a); } }%关键差异头文件Cocos使用#include引入内置Uniform如cc-global,cc-light。变量传递通过v_normal,v_position等从顶点着色器传入。内置变量cc_cameraPos是摄像机位置cc_ambientLight是环境光颜色cc_mainLitLight可能包含主光源信息具体需查阅当前版本文档。语法使用GLSL ES语法如texture代替SAMPLE_TEXTURE2D。5.2 实战应用场景拓展理解了漫反射和高光就能创造出丰富的视觉效果卡通渲染将NdotL的连续变化通过step或smoothstep函数离散成几个色阶就能得到卡通风格的漫反射。高光部分也可以处理成一个明亮的硬边圆片。边缘光计算dot(normal, viewDir)当值接近0即视线与表面切线方向近乎垂直时给一个发光效果常用于突出角色轮廓。MatCap一种简化但高效的高光模拟。将计算好的高光图案存储在一张球面纹理上根据法线方向直接采样省去了复杂的光照计算常用于移动端或风格化渲染。顶点高光对于性能极度受限的场景如大量植被可以将高光计算放在顶点着色器片元着色器只做插值。虽然精度下降但能节省大量计算。光照模型是Shader学习的基石。漫反射和高光反射案例就像学会了加减乘除虽然简单但却是后续学习更复杂数学模型如PBR、次表面散射、各向异性高光的必备基础。我个人的习惯是每学一个新的光照模型都会脱离引擎在一个纯OpenGL或WebGL的环境里用手写矩阵和向量的方式实现一遍。这个过程能强迫你理解每一个数据的来源和去向虽然痛苦但理解会深刻得多。当你再回到Unity或UE这样的高级引擎看到那些封装好的函数时你就能清楚地知道它们背后在做什么出了问题也知道该从哪里排查。
Shader编程入门:从Lambert漫反射到Blinn-Phong高光模型实战解析
1. 项目概述从“看得见”到“看得懂”的光照模型在图形渲染的世界里我们写的每一行Shader代码本质上都是在回答一个问题这个像素点在当前的灯光和视角下应该是什么颜色这听起来简单但背后是一整套模拟光线与物体表面交互的物理和数学模型。对于刚接触Shader编程的朋友来说漫反射和高光反射就是打开这扇门的钥匙。它们不是Unity、Cocos Creator或者Unreal Engine的专属而是计算机图形学中描述物体表面基础光照特性的两个核心光照模型。漫反射模拟的是光线在粗糙表面上的均匀散射。想象一下一张粗糙的A4纸无论你从哪个角度看它的亮度都差不多这就是漫反射的效果。它决定了物体表面的基础颜色和明暗关系是物体能被我们“看见”的基础。而高光反射模拟的则是光线在光滑表面上的镜面反射。就像你看到的不锈钢水杯上那个亮晶晶的光斑它会随着你的视角移动而移动这个光斑就是高光。它赋予了物体“光泽感”和“质感”是区分塑料、金属、陶瓷等不同材质的关键。网上有海量的Shader案例和代码片段但很多时候我们只是“复制-粘贴”看到了效果却不明白为什么这几行代码就能产生这样的效果。这个项目的目的就是带你亲手实现这两个基础光照模型并彻底搞懂背后的数学原理和实现细节。我们会从最基础的Lambert漫反射模型和Phong高光模型开始一步步推导公式、编写代码并探讨它们在实际项目中的应用与优化。无论你是想为你的游戏角色增添真实的皮革质感还是想让UI界面产生精致的金属反光理解这些基础都是必经之路。2. 核心光照模型原理深度拆解要动手写Shader不能只停留在“效果”层面必须深入理解光照模型背后的物理假设和数学表达。这就像学做菜不能只看菜谱得明白为什么加热、为什么调味。2.1 漫反射Lambert模型的来龙去脉漫反射的核心思想是光线到达物体表面后向各个方向均匀地散射出去。这意味着从任何方向观察该点的亮度都是相同的。但亮度与什么有关呢与光线照射到表面的角度有关。这里引入一个关键概念表面法线。它是垂直于物体表面某一点的一个单位向量。光线方向与法线方向的夹角决定了光线照射的“正”与“斜”。Lambert定律用余弦函数来描述这种关系表面接收到的光强与光线方向向量和表面法线向量夹角的余弦值成正比。夹角为0度光线垂直照射时余弦值为1光强最大夹角为90度光线擦过表面时余弦值为0光强为0。在Shader中我们通常处理的是单位向量。设光源方向为L指向光源表面法线为N那么漫反射光强系数就是dot(N, L)的点积结果。但这里有个细节点积结果可能是负数当夹角大于90度意味着光线来自表面背面而光强不能为负。所以实际计算时我们需要使用max(dot(N, L), 0)来钳制结果。最终漫反射颜色计算公式为漫反射颜色 光源颜色 * 表面漫反射颜色 * max(dot(N, L), 0)注意这里的L在计算时通常需要从世界空间或模型空间转换到合适的坐标系如切线空间并与同样在该空间下的法线N进行计算确保两者在同一个坐标空间下这是新手最容易出错的地方之一。2.2 高光反射Phong与Blinn-Phong的抉择高光反射模拟的是镜面反射。我们能看到高光是因为眼睛摄像机恰好位于光线经表面反射后的反射光方向上。Phong模型直接模拟了这一物理过程。它需要三个向量视线方向V从表面点指向摄像机光线方向L法线方向N。首先根据N和L计算出反射光方向R。然后计算视线方向V与反射方向R的夹角。夹角越小说明视线越接近完美的反射方向看到的高光就应该越强。同样用点积的余弦值dot(V, R)来衡量并取其最大值。但Phong模型发现现实中的高光衰减并非线性的。夹角增大时高光强度会急剧下降。因此引入了一个光泽度参数通常称为Gloss或Shininess。通过一个指数函数来模拟这种衰减pow(max(dot(V, R), 0), gloss)。gloss值越大高光点越集中、越锐利材质看起来越像光滑的金属或瓷器值越小高光越分散、越柔和类似塑料或布料。Phong模型物理意义明确但计算反射向量R需要用到reflect函数有一定开销。Blinn-Phong模型提出了一种优化方案。它不计算反射向量R而是引入一个半角向量H。H是光线方向L和视线方向V的角平分线方向归一化后的L V。然后通过计算法线N与半角向量H的夹角来模拟高光公式为pow(max(dot(N, H), 0), gloss)。Blinn-Phong模型在计算上更高效少了一次反射计算并且在视觉效果上当gloss值较大时其高光范围比Phong模型更“宽”一些有时被认为更符合某些真实材质的观感。因此在实时渲染中Blinn-Phong的应用更为广泛。最终高光颜色计算公式为以Blinn-Phong为例高光颜色 光源颜色 * 高光反射颜色 * pow(max(dot(N, H), 0), gloss)2.3 环境光不可或缺的补光者一个只有漫反射和高光的物体在背对光源的一面会完全漆黑这不符合我们对现实世界的认知。因为光线会在场景中多次反弹。为了简化计算我们引入一个常量——环境光。它假设所有物体都受到一个均匀的基础光照无论其朝向如何。环境光的计算最简单环境光颜色 全局环境光颜色 * 表面环境光颜色在最终的片元着色器中我们将环境光、漫反射光和高光反射光的效果叠加起来就得到了该像素点的最终颜色。这就是经典的前向渲染逐像素光照的基础框架。3. 在Unity URP中实现基础光照Shader理论需要实践来验证。我们选择在Unity的通用渲染管线中实现因为URP是当前Unity开发的主流其Shader编写结构与Built-in管线有差异更需要清晰的指引。我们将创建一个完整的、包含漫反射和高光的Shader。3.1 项目准备与Shader框架搭建首先在Unity中创建一个URP项目。在项目窗口中右键选择Create - Shader - Universal Render Pipeline - Lit Shader并命名为SimpleSpecular。Unity会为我们生成一个基于PBR的复杂Shader模板但我们为了学习需要从一个更简单的无光照Shader模板开始修改。更清晰的做法是直接创建新的Shader文件。我们可以创建一个文本文件将后缀改为.shader然后从头编写。一个URP Shader的基本结构如下Shader Custom/SimpleDiffuseSpecular { Properties { // 属性块定义在材质面板中可调节的参数 _BaseColor (Base Color, Color) (1,1,1,1) _BaseMap (Base Map, 2D) white {} _Gloss (Gloss, Range(8.0, 256)) 20 _SpecularColor (Specular Color, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Name ForwardLit Tags { LightModeUniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag // 包含URP的核心库文件 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // 定义与Properties块对应的变量 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); float4 _BaseMap_ST; // 用于纹理的缩放和偏移 half4 _BaseColor; half _Gloss; half4 _SpecularColor; // 定义顶点着色器的输入结构 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; // 定义顶点着色器输出到片元着色器的结构 struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; }; // 顶点着色器 Varyings vert(Attributes IN) { Varyings OUT; // 将顶点位置从物体空间转换到齐次裁剪空间 VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; // 将法线从物体空间转换到世界空间 VertexNormalInputs normalInputs GetVertexNormalInputs(IN.normalOS); OUT.normalWS normalInputs.normalWS; // 计算世界空间顶点位置 OUT.positionWS positionInputs.positionWS; // 处理纹理UV支持材质面板的Tiling和Offset OUT.uv TRANSFORM_TEX(IN.uv, _BaseMap); return OUT; } // 片元着色器 half4 frag(Varyings IN) : SV_Target { // 后续的光照计算将在这里进行 return half4(1,1,1,1); } ENDHLSL } } }这个框架包含了URP Shader的必要元素属性定义、SubShader和Pass标签、HLSL代码块、以及顶点和片元着色器的基本结构。GetVertexPositionInputs和GetVertexNormalInputs是URP提供的工具函数帮我们处理了空间转换的复杂细节。3.2 逐像素光照计算实现现在我们在片元着色器frag函数中实现光照计算。URP提供了GetMainLight函数来获取场景中的主光源信息这比我们自己管理光源方便得多。half4 frag(Varyings IN) : SV_Target { // 采样纹理颜色 half4 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 获取主光源数据 Light mainLight GetMainLight(); float3 lightColor mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation; float3 lightDir normalize(mainLight.direction); // 光源方向从表面指向光源 // 准备向量数据 float3 normalWS normalize(IN.normalWS); // 世界空间法线 float3 viewDirWS normalize(_WorldSpaceCameraPos - IN.positionWS); // 视线方向表面指向相机 // 1. 环境光 (简化处理使用URP中的环境球谐采样或一个常量) half3 ambient half3(unity_SHAr.w, unity_SHAg.w, unity_SHAb.w); // 简化环境光 // 更准确的做法half3 ambient SampleSH(normalWS); // 2. 漫反射计算 (Lambert) float NdotL max(dot(normalWS, lightDir), 0.0); half3 diffuse lightColor * albedo.rgb * NdotL; // 3. 高光反射计算 (Blinn-Phong) float3 halfDir normalize(lightDir viewDirWS); // 计算半角向量 float NdotH max(dot(normalWS, halfDir), 0.0); float specularTerm pow(NdotH, _Gloss); half3 specular lightColor * _SpecularColor.rgb * specularTerm; // 4. 最终颜色合成 half3 finalColor ambient * albedo.rgb diffuse specular; return half4(finalColor, albedo.a); }这段代码完成了核心的光照计算。有几个关键点需要注意向量归一化在计算点积前确保normalWS、lightDir、viewDirWS都是单位向量。虽然从顶点着色器插值出来的法线可能接近单位向量但为了精度在片元着色器中重新归一化是良好习惯。光源衰减mainLight.distanceAttenuation和mainLight.shadowAttenuation分别代表了光源的距离衰减和阴影衰减。将它们乘入lightColor能让光照效果更真实。环境光简化这里使用了unity_SHAr.w等内置变量来获取一个简化的环境色。对于更高质量的环境光应使用SampleSH(normalWS)函数来采样球谐光照数据这能提供基于法线方向的动态环境反射。实操心得在URP中_WorldSpaceCameraPos是内置变量表示世界空间下的摄像机位置。计算视线方向时一定是摄像机位置 - 表面点位置得到一个从表面指向摄像机的向量。这个顺序反了高光计算就会完全错误。3.3 材质创建与效果调试Shader编写完成后在项目窗口中右键该Shader文件选择Create - MaterialUnity会自动创建一个使用该Shader的材质球。将这个材质球拖拽到场景中的物体上。在场景中创建一个Sphere球体和一个Directional Light平行光。将新建的材质赋给球体。调整材质面板上的参数观察效果调整_BaseColor改变物体的基础色。调整_Gloss值观察高光范围的变化。值越小高光越散越柔和值越大高光越集中越锐利。调整_SpecularColor可以改变高光的颜色。金属的高光颜色通常接近光源色白色而非金属如塑料的高光颜色可能更接近其表面色。尝试旋转平行光观察球体上漫反射明暗和高光位置的变化。移动摄像机视角高光位置应随之移动这正是Blinn-Phong模型的特征。4. 进阶探讨常见问题、优化与扩展实现基础功能只是第一步。在实际项目中我们会遇到各种问题也需要考虑性能和效果的扩展。4.1 常见问题与排查技巧实录即使代码逻辑正确渲染结果也可能不如预期。下面是一个常见问题速查表问题现象可能原因排查步骤与解决方案物体全黑或颜色异常1. 法线数据错误。2. 光源方向计算错误。3. Shader编译错误未生效。1. 在片元着色器输出normalWS作为颜色查看return half4(IN.normalWS * 0.5 0.5, 1.0)。法线应呈现平滑的渐变。2. 输出lightDir或NdotL值查看。确保lightDir是从表面指向光源。3. 检查Unity控制台是否有Shader编译错误红色错误信息。高光位置不对或不动1. 视线方向viewDir计算错误。2. 半角向量halfDir计算错误。3. 所有向量未在同一坐标空间。1. 输出viewDir作为颜色移动相机观察颜色变化。2. 检查halfDir normalize(lightDir viewDir)确保是加法且已归一化。3.最易错点确认normalWS,lightDir,viewDir都在世界空间下计算。lightDir来自GetMainLight()已是世界空间。高光边缘有锯齿_Gloss值很大时pow函数计算的高光边缘很锐利在低分辨率下产生锯齿。1. 使用smoothstep或更平滑的函数替代pow进行边缘抗锯齿。2. 增加渲染分辨率或使用后期处理的抗锯齿如MSAA、FXAA。背光面有奇怪高光当NdotL 0时背光面我们虽然屏蔽了漫反射但高光计算仍在进行。在高光计算前加入判断if (NdotL 0.0) specularTerm 0.0;这是符合物理的因为背光面不应有直接光源产生的高光。性能开销大每像素都进行pow运算且_Gloss是变量无法被Shader编译器优化。对于移动平台可以考虑将高光计算移到顶点着色器然后在片元着色器插值效果较差。或者使用查找表来近似pow计算。踩坑记录我曾在一个项目中高光怎么调都不对最后发现是导入的FBX模型没有法线信息。Unity虽然会生成平滑法线但有时并不准确。对于自定义模型务必在3D建模软件中检查并导出正确的法线。4.2 从Phong到PBR的思维跨越我们实现的Blinn-Phong模型是一个经验模型它效果不错且计算快但物理准确性不足。现代游戏和3A大作普遍采用基于物理的渲染模型。PBR的核心在于能量守恒和微表面理论。它认为物体表面是由无数个微小的镜面组成的。粗糙度决定了这些微表面的朝向分布从而影响漫反射和高光的比例。PBR通常使用两个核心参数金属度表示材质是金属还是非金属。金属的漫反射很弱其颜色主要来自对环境的镜面反射即高光颜色与光源色强相关。非金属则有明显的漫反射。粗糙度取代了_Gloss但意义相反。粗糙度越高表面越粗糙高光越分散。在URP的Lit Shader中已经完整实现了PBR。理解了我们手写的Blinn-Phong之后再去学习PBR的BRDF方程就会明白那些复杂的项如法线分布函数D、几何遮蔽函数G、菲涅尔方程F各自在模拟什么物理现象学习曲线会平滑很多。4.3 在Shader Graph中可视化构建对于不习惯写代码的美术或技术美术同学Unity的Shader Graph和Amplify Shader Editor这类可视化工具是绝佳选择。它们背后的节点其实就是我们上面编写的代码片段。例如构建一个简单的Blinn-Phong光照通过Sample Texture 2D节点获取基础色。使用Normal Vector节点获取法线。使用Light Direction和View Direction节点获取光源和视线方向。用Dot Product节点计算NdotL用Add和Normalize节点计算半角向量H再用一个Dot Product计算NdotH。用Power节点实现pow(NdotH, Gloss)。最后用Multiply和Add节点将环境光、漫反射、高光组合起来。在Shader Graph中拖拽节点、连接连线的过程能非常直观地理解数据是如何流动和计算的是对代码学习极好的补充。你可以尝试用Shader Graph复现我们上面代码的效果感受可视化编程的逻辑。5. 多引擎下的实现异同与实战应用掌握了原理就可以在不同引擎和工具中游刃有余。思路是相通的只是API和语法不同。5.1 在Cocos Creator中的实现要点Cocos Creator的Shader编写类似于GLSL ES。其内置了一些有用的Uniform和函数。// Cocos Creator Shader 片段示例 (Effect写法) CCProgram fs %{ precision highp float; #include builtin/uniforms/cc-global #include builtin/uniforms/cc-light in vec3 v_normal; in vec3 v_position; in vec2 v_uv; uniform sampler2D mainTexture; uniform vec4 mainColor; uniform float gloss; uniform vec4 specularColor; vec4 frag () { vec4 albedo texture(mainTexture, v_uv) * mainColor; // 获取主平行光简化处理Cocos可能有多光源管理 // 这里假设cc_mainLitLight是引擎提供的主光数据 vec3 lightDir normalize(cc_mainLitLight.direction); vec3 lightColor cc_mainLitLight.color; vec3 normal normalize(v_normal); vec3 viewDir normalize(cc_cameraPos.xyz - v_position); // 环境光 (使用引擎内置的环境色) vec3 ambient cc_ambientLight.rgb * albedo.rgb; // 漫反射 float NdotL max(dot(normal, lightDir), 0.0); vec3 diffuse lightColor * albedo.rgb * NdotL; // 高光 (Blinn-Phong) vec3 halfDir normalize(lightDir viewDir); float NdotH max(dot(normal, halfDir), 0.0); float specularTerm pow(NdotH, gloss); vec3 specular lightColor * specularColor.rgb * specularTerm; vec3 finalColor ambient diffuse specular; return vec4(finalColor, albedo.a); } }%关键差异头文件Cocos使用#include引入内置Uniform如cc-global,cc-light。变量传递通过v_normal,v_position等从顶点着色器传入。内置变量cc_cameraPos是摄像机位置cc_ambientLight是环境光颜色cc_mainLitLight可能包含主光源信息具体需查阅当前版本文档。语法使用GLSL ES语法如texture代替SAMPLE_TEXTURE2D。5.2 实战应用场景拓展理解了漫反射和高光就能创造出丰富的视觉效果卡通渲染将NdotL的连续变化通过step或smoothstep函数离散成几个色阶就能得到卡通风格的漫反射。高光部分也可以处理成一个明亮的硬边圆片。边缘光计算dot(normal, viewDir)当值接近0即视线与表面切线方向近乎垂直时给一个发光效果常用于突出角色轮廓。MatCap一种简化但高效的高光模拟。将计算好的高光图案存储在一张球面纹理上根据法线方向直接采样省去了复杂的光照计算常用于移动端或风格化渲染。顶点高光对于性能极度受限的场景如大量植被可以将高光计算放在顶点着色器片元着色器只做插值。虽然精度下降但能节省大量计算。光照模型是Shader学习的基石。漫反射和高光反射案例就像学会了加减乘除虽然简单但却是后续学习更复杂数学模型如PBR、次表面散射、各向异性高光的必备基础。我个人的习惯是每学一个新的光照模型都会脱离引擎在一个纯OpenGL或WebGL的环境里用手写矩阵和向量的方式实现一遍。这个过程能强迫你理解每一个数据的来源和去向虽然痛苦但理解会深刻得多。当你再回到Unity或UE这样的高级引擎看到那些封装好的函数时你就能清楚地知道它们背后在做什么出了问题也知道该从哪里排查。