
先回答一个我经常被问到的问题为什么网上能搜到大量现成Shader我却还是要花时间研究自定义光照模型答案很简单能直接解决项目里特定效果的Shader往往买不到、找不着只能自己写。过去几年我一直泡在图形渲染这块从Unity到自研引擎都有涉及真正让画面产生质变的比如风格化卡通光照、皮肤次表面、武器高光过渡几乎都不是靠调内置参数调出来的而是靠亲手改Shader做到的。这篇文章我不想写“Shader入门Hello World”而是想沿着一条实打实的路走一遍从GPU渲染管线的底层逻辑到光照模型的数学拆解再到手写一个可自行修改、扩展的自定义光照模型。适合那些刚会用引擎、但想真正进入Shader编程新维度的朋友也包括想搞懂自己项目里那些“魔改”光照效果原理的人。1. 为什么自定义光照模型是渲染进阶的必修课1.1 从固定管线到可编程管线渲染控制权回到了程序员手里很多年轻人入行遇到的第一道坎是分不清“光照”和“灯光”这两个词。灯光是场景里摆的一个个光源对象光照则是计算这些光源影响后物体表面颜色的过程。早年GPU还是固定管线时代光照算法是硬件焊死的开发者只能在几个参数里打转比如设置Ambient颜色、Diffuse材质色、Specular强度然后用OpenGL/ DirectX内置的Lighting模型去渲染。结果是大家都长一张“塑料脸”想要个日式赛璐璐效果都用不上力。可编程Shader出现后情况彻底变了。顶点着色器和片元着色器把光照计算的权柄交到了开发者手里你想让漫反射在某个角度突然断层想让玩具质感的高光中间掏空都能通过写代码实现。很多人误以为“懂Shader”就是把语法背熟其实最关键的是理解从固定管线到可编程管线的这一跳以前你只能开关硬件按钮现在你得自己设计电路。这也是为什么自定义光照模型成了图形学进阶的必修课它是Shader编程从“会抄”到“会写”的分水岭。1.2 语言与工具链选型Shader Graph、代码Shader还是直接在着色器层面调驱动开始动手之前先聊工具链。不同引擎有不同的Shader描述方式Unity主要是ShaderLab配合CG/HLSLUnreal以USF为主Web端又有GLSL。很多人问我现在是不是应该无脑用Unity Shader Graph这类节点工具我的观点是节点工具适合快速验证、美术出效果但如果你要做一个完整自定义光照模型最后还是要回到代码。因为Shader Graph的Master Node帮你封装了大量选择遇到它没暴露的选项你就卡住了。另一方面Cocos Creator里经常有人做Label的渐变Shader原理其实就是在片元着色器里对UV或Alpha做线性映射这类小效果用节点工具倒是很顺手但背后如果不懂lerp、saturate这些函数依然只能对着别人发出来的节点图照抄。做底层图形调试时我还会经常用到Mesa、Zink这类开源图形栈。Mesa是Linux上最常见的OpenGL/Vulkan实现Zink则是让OpenGL跑在Vulkan之上的一层。它们看起来离Unity用户很远但如果你做跨平台项目或者在云渲染、Linux服务器上跑离屏渲染这些都是绕不开的。所以更实际建议是先选择一个主战场比如Unity的手写Shader把数据和数学基础打扎实再把工具链往外扩Shader Graph当辅助驱动级别的排查当进阶。1.3 Shader编程新维度从“调参”到“定义结果”为什么叫“新维度”因为大多数人做渲染特效本质是在已有Shader上调数字粗糙度拉到0.8高光压低一点看起来“还行”。自定义光照模型则要求你回答一个根本问题一个像素最终的颜色应当由哪些因素以什么方式组合是你定规则而不是你在别人的规则里找参数。这个转变带来的自由度和风险都很大。自由度大是因为你可以为美术风格创造专属着色器风险大是因为一旦计算结果超出[0,1]范围、法线方向算错、向量没归一化产出的视觉问题会让你排查到怀疑人生。我在带团队时经常让大家做一个小练习不看任何内置函数用最原始的HLSL/CG实现一个能反映“主光源环境色高光”的Shader。不要用Unity内置的Lighting.cginc实际包含的那些宏全手写。只要做过一次之后再去看PBR、卡通渲染、毛发各向异性高光脑子里都会有一个清晰的“计算链路”而不是一团浆糊。这就是自定义光照模型训练的核心价值。2. 光照模型背后的数学原理才是自定义的底气2.1 一个像素的颜色由哪几部分叠加而成在真实感渲染里我们在片元着色器看到的一切基本都是“光照结果”。传统局部光照模型把最终颜色拆成四块环境光Ambient、漫反射Diffuse、高光Specular、自发光Emission。环境光模拟的是四面八方都能弹射到表面的间接光早期为了省事就粗暴地用一个常量去乘材质颜色漫反射是光源照亮粗糙表面后向四周均匀反射的部分高光则是表面在特定角度反射光源的那部分能量视角一变它会跟着移动。这四块加起来构成了一个能看但不够真的物体。自定义光照模型的本质就是重新定义这几块分别怎么算。你不需要完全遵循“现状”比如很多卡通渲染会把高光做成一块色斑漫反射用两个阶梯过渡这完全可以在数学上实现。但无论怎么改光照模型处理的核心数据始终是那几个方向向量表面法线N、光源方向L、视线方向V。只要这三个方向定义正确后边怎么写都行了。2.2 兰伯特漫反射和半兰伯特背光面处理的分水岭漫反射的经典模型是兰伯特Lambert公式长这样diffuse max(dot(N, L), 0) * albedoColor * lightColor几何意义是“表面接收到多少光能与它朝向光源的程度成正比”。当N与L完全同向时dot为1接收最多光当N与L垂直时dot为0接收不到光。max(dot(N,L),0)是为防止背光面出现负值颜色负值在显示器上没法显示如果不截断就会出现黑斑。但兰伯特有个老问题背光面直接变成纯黑动画、游戏中会显得死板。所以游戏圈又流行半兰伯特Half Lambert做法把点积从[-1,1]映射到[0,1]公式是dotNL * 0.5 0.5。这样一来即使背面也有一个不算小的亮度画面更柔和当年很多端游用它来模拟次表面散射的“通透感”。我至今做偏二次元风格的项目时漫反射部分都默认用半兰伯特再配一张Ramp贴图效果比纯物理的兰伯特更可控。2.3 Blinn-Phong高光半程向量的巧妙之处高光模型我们最常用的是Blinn-Phong而不是更传统的Phong。Phong模型需要计算光线的反射向量R再求R和视线V的夹角反射向量公式里有两次点积和一次向量缩放GPU算着麻烦而且当视线与反射向量夹角超过90度时会出现不连续。Blinn-Phong引入了一个半程向量H定义为光源方向L和视线方向V的中间方向H normalize(L V); spec pow(max(dot(N, H), 0), gloss) * lightColor * specColor;为什么叫“半程”因为H正好在L和V之间如果N越接近H说明表面微平面越能把你看到的那束光反射进眼睛。gloss也叫高光系数控制高光范围大小数值越小高光越柔和越大越尖锐。半程向量H算一次比反射向量R省不少指令而且在高光边缘过渡上更平顺。这是我建议所有入门者手推一遍的公式因为它把“观察者方向参与光照计算”这件事表达得很清楚。2.4 从经验光照到PBR自定义不是倒退现在PBR基于物理的渲染成了主流Disney、UE、Unity都在推金属度、粗糙度、AO那套东西。有人就疑惑既然物理光照模型都这么成熟了为什么还要自己造轮子这里要分清目的。PBR目标是“真实”但游戏渲染并不总是追求真实。卡通渲染、水墨渲染、潮流风格化、UI特效统统需要定制模型而这些模型很多就是基于兰伯特、Blinn-Phong甚至更简单的经验公式改造来的。理解经验模型能让你明白PBR在哪些环节做了物理修正也方便你在风格化项目里做有限的物理模型简化和改写。所以我的建议是不要因为PBR流行就跳过传统光照模型。传统模型里那些参数Diffuse、Specular、Gloss是理解渲染的“最小语法”。你先会写Blinn-Phong再去理解PBR里的BRDF、法线分布函数GGX、菲涅尔项会发现一切都有迹可循。3. 实战手写一个可拓展的自定义光照模型3.1 准备工作Unity ShaderLab中的Pass结构用Unity作为演示环境比较方便因为它把跨平台编译和内置变量都处理好了我们能专注于光照逻辑。打开Unity创建Shader文件默认会出现一个标准表面着色器模板。我们直接清空逻辑从最简单的结构开始Shader Custom/MyBlinnPhong { Properties { _MainTex (Albedo, 2D) white {} _Diffuse (Diffuse, Color) (1,1,1,1) _Specular (Specular, Color) (1,1,1,1) _Gloss (Gloss, Range(8, 256)) 32 } SubShader { Tags { RenderTypeOpaque LightModeForwardBase } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 worldPos : TEXCOORD0; float3 worldNormal : TEXCOORD1; float2 uv : TEXCOORD2; }; sampler2D _MainTex; float4 _MainTex_ST; float4 _Diffuse; float4 _Specular; float _Gloss; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { return fixed4(1, 0, 0, 1); } ENDCG } } }先让画面出现一个纯红色球体说明Shader能跑。这里有个关键词Pass。一个Shader可以有多个PassUnity在最简单情况下只用ForwardBase这个Pass来接收主光源。Tags里的LightModeForwardBase是告诉渲染管线这个Pass是用于前向渲染的基础光照。3.2 顶点着色器该算些什么顶点着色器的主要工作是“把顶点从模型空间挪到裁剪空间”同时把后续片元着色器需要的数据准备好。我的习惯是提前把一切和光照计算有关的向量都转换到世界空间。为什么因为光源位置、相机位置、多数阴影信息都在世界空间如果法线留在模型空间那每个顶点都得额外做一次坐标系换算反而更乱。法线和顶点位置不同它不能被直接用mul(unity_ObjectToWorld, v.normal)变换因为非等比缩放会破坏法线方向。更准确的做法是用法线变换矩阵即物体变换矩阵的逆转置矩阵。Unity提供了内置方法UnityObjectToWorldNormal它会帮我们处理这部分。很多新手在这踩坑只做了顶点变换没有正确变换法线结果光照斑驳一块块错位看起来就像法线贴图出错了。片元着色器拿到worldNormal和worldPos后就能开始算光照了。但这里要注意worldNormal在插值后长度会变短所以片元里一定要再normalize一次。别嫌这一步多余GPU插值会改变向量长度不归一化会造成高光形状和强度误差这种问题特别隐蔽。3.3 片元着色器光照计算的核心现在把片元着色器补全实现前面讲的Blinn-Phongfixed4 frag (v2f i) : SV_Target { float3 N normalize(i.worldNormal); float3 L normalize(_WorldSpaceLightPos0.xyz); float3 V normalize(_WorldSpaceCameraPos.xyz - i.worldPos); float3 H normalize(L V); float ndl saturate(dot(N, L)); float ndh saturate(dot(N, H)); fixed3 albedo tex2D(_MainTex, i.uv).rgb * _Diffuse.rgb; fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * albedo; fixed3 diffuse albedo * _LightColor0.rgb * ndl; fixed3 specular _LightColor0.rgb * _Specular.rgb * pow(ndh, _Gloss) * ndl; return fixed4(ambient diffuse specular, 1.0); }这段代码里最需要注意的是_WorldSpaceLightPos0。当前向渲染只有一个平行光时用它可以直接拿到光源方向但如果是点光源或聚光灯这里的xyzw含义有区别。严谨做法应该区分LightMode比如处理点光源时用ObjSpaceLightDir或者归一化的光源位置减去顶点位置。我在实际项目里通常会封装一个函数GetLightDirection(worldPos)处理光源类型分支。_LightColor0是平行光颜色UNITY_LIGHTMODEL_AMBIENT是环境光。这里的specular我乘了一个ndl表示背光面不应该出现高光这是Blinn-Phong的常见修正。有人喜欢不乘认为高光是独立的光线反射行为。到底乘不乘取决于你想要“物理正确度”还是“视觉风格”。如果你想做真实感乘如果追求二次元角色头发上那一束凌驾于阴影之上的高光不乘反而更好。自定义光照模型的价值就体现在这些细节取舍上。3.4 扩展1卡通光照与Ramp贴图Blinn-Phong能跑通后我们可以给漫反射加一点“性格”。比如卡通渲染中最常见的做法是把连续的漫反射结果离散化。最简单的方式是拿一个阶梯函数float toonNdl floor(ndl * _StepCount) / _StepCount; fixed3 diffuse albedo * _LightColor0.rgb * toonNdl;_StepCount是阶数比如2就是两段明暗适合复古赛璐璐4就是四档稍微细腻一点。如果你不想硬切还可以用smoothstep(0.4, 0.6, ndl)做一个带软化边缘的阈值视觉上比step柔和得多。另外很多日系渲染会直接用Ramp贴图渐变纹理查表把ndl作为UV横坐标采样一张美术手绘的漫反射渐变图。这样只要美术画一张渐变一个角色身上就能呈现从冷灰到暖肤色的丰富明暗这个思路把“手动计算”转化为“数据驱动”非常实用。这里多说一句Cocos引擎里常见的Label渐变Shader本质上也是一种一维渐变查表把横向或纵向UV映射到渐变颜色上用它去混合原始文字颜色。原理和我们这里Ramp贴图查ndl是完全一致的。懂了RampUI的渐变材质你也能看懂大半。3.5 扩展2遮蔽、多光源与后续方向完成基础光照后如果你要放到真实游戏里还需要考虑三个问题阴影、多光源、前向与延迟渲染的区别。阴影主要靠ShadowMapUnity里一般用SHADOW_COORDS、SHADOW_ATTENUATION宏来做这部分建议直接用引擎封装手写从0到1需要大量工程细节。多光源则需要在ForwardAdd的Pass里追加一份叠加光照。很多自定义Shader只写了一个Pass放进场景里只能收到主光其余点光源一照就“黑脸”这是最常见的实用性问题。从代码维护角度建议把“光照计算”抽成单独的函数比如float3 LightingBlinnPhong(float3 worldNormal, float3 worldPos, float3 albedo)这样后续加Ramp、加遮罩、加各向异性都只需要改函数内部。我做风格化角色时还会再叠加一张高光遮罩贴图控制金属饰物和皮肤的高光强弱这些都是在自定义函数的基础上加几个参数而已。4. 我踩过的坑调试方法与跨平台兼容性4.1 黑屏、花屏和粉色材质怎么排查Shader调试和普通C#调试完全不同最麻烦的地方是你得靠眼睛判断结果而不是打日志。粉色材质十有八九是编译错误Unity会把错误直接打印到Console仔细看是哪一行语法错了。黑屏常见原因有三个一是片元里所有输出都是0检查你的albedo和ambient二是计算结果出现NaN通常是除零或者pow对负数开方向量没有归一化最容易引发三是法线空间不对导致所有方向点积都为负数最终被saturate压成0也呈现黑脸。花屏就要看具体模式。如果高光位置不对优先怀疑法线变换和世界坐标如果阴影闪烁多半是深度比较精度问题而不是Shader本身如果边缘颜色诡异检查是否用了UV坐标但没处理好平台差异。我有个习惯先在片元着色器里写死返回float4(N, 1)看看法线能不能正常呈现红绿蓝渐变。有时候小球转起来高光纹丝不动我就知道法线根本不是预期方向。4.2 跨API与驱动的坑Zink、Mesa Shader Cache和glthreadUnity Shader写出来还要经过HLSL编译成GLSL、Metal、Vulkan等多种底层语言。不同API对精度、坐标朝向、资源绑定的要求不同。比如OpenGL的V坐标方向是从左下角开始Vulkan的Y轴翻转则经常让贴图上下颠倒Metal和Vulkan对统一缓冲区的对齐要求也不同。这些兼容性坑有时候不是Shader本身的逻辑错而是平台约定差异。如果你在Linux桌面环境调试跨API项目可能会接触到Mesa、Zink、Wine之类的组合。Zink是把OpenGL翻译到Vulkan的一层跑大型Shader时对Descriptor Set的处理特别敏感。有次我项目里一个全屏后处理Shader在原生OpenGL下正常用Zink跑却随机闪黑块排查到最后是同一帧里Uniform更新太频繁把Descriptor池打爆了改成批量更新后问题消失。这种问题在Windows和原生驱动上根本不会出现只有到了翻译层才暴露。另外一个特别有用的排查技巧是用Mesa驱动调Shader时先设一下MESA_SHADER_CACHE_DISABLEtrue再跑因为这个环境默认会缓存编译过的Shader旧缓存可能让你以为“改了代码没生效”禁掉缓存后再定位问题会干净很多。此外Mesa的MESA_GLTHREAD参数允许GL线程在多个CPU线程上并发处理有时能明显提升Draw Call吞吐但它也会改变驱动内部同步行为不确定时最好对照实测帧率再决定开或关。4.3 常见问题速查表现象可能原因排查方法材质变粉色Shader编译失败打开Console看报错查看行号和变量名模型全黑法线没归一化/漫反射结果被截断返回法线颜色调试检查N和L方向高光位置不对法线变换用了模型缩放矩阵改用UnityObjectToWorldNormal贴图反了OpenGL/Vulkan坐标差异调整UV翻转逻辑或使用引擎的UV处理宏不同驱动下亮度不同使用了无精度的float/half混用统一精度注意half作用域Zink下随机闪黑块Descriptor Set池不足减少SingleDraw高频Uniform修改改Shader没变化Mesa/驱动层Shader缓存设置MESA_SHADER_CACHE_DISABLE后重跑这张表看起来琐碎但实际项目里80%的时间都花在这些问题上。建议你建一个自己的“Shader Debug日志”每遇到一个新问题就补充一行时间长了就是最适合自己的避坑宝典。4.4 性能优化的几个硬指标写完功能之后我们还得让Shader跑得快。GPU是并行机器一个问题不解决就是全屏每像素一起遭殃。第一尽量少用动态分支。if在GPU里会破坏并行执行效率能改成step、lerp、saturate这类数学函数就尽量改。第二纹理采样要控制数量采样次数越多带宽压力越大Ramp贴图这种一维查询问题不大但多张Detail贴图叠加就要注意。第三运算精度按需分配颜色类数据用fixed或half世界坐标等位置数据用float别一律float到底。第四Shader变体别乱开越多的#pragma multi_compile就意味着运行时有更多编译组合内存和加载时间都是成本。我平时会把它简化成一句话“先让代码能看再让代码能跑最后让代码跑得快。”自定义光照模型尤其如此因为你要改的往往不是一两个宏开关而是整套计算链路如果一开始就在意性能会限制你尝试的勇气如果最后不考虑性能又会坑到美术和播放帧率。平衡点就在“先找到效果再回头砍指令”。5. 写在最后的实操心得前几天有人问我做图形渲染这么多年最深的体会是什么。我的回答是Shader编程真正难的不是语法而是建立“每个输出都能从输入推导出来”的信念。当你看到一个高光不对劲不要凭感觉乱调参数先确认N、L、V三个向量到底在哪个空间、长度是否正常再往下查。自定义光照模型不是炫技它是在用最直接的方式告诉你渲染结果是你对数学、对数据、对设备理解的总和。最后分享一个我保持了很多年的习惯。每写完一个新光照模型我会立刻搭一个只有一盏平行光、一个球、一个立方体、灰色背景的测试场景把Gloss、Diffuse、贴图开关、光照方向变化都截图记录下来。这样既能快速对比效果也能在后续调参时回头追溯“上次好看那版到底是几号参数”。图形渲染是一个眼睛说了算的领域有记录才有复盘的依据。希望这篇文章能帮你迈过从“会抄Shader”到“会写自定义光照模型”的那道坎也欢迎你从Blinn-Phong那版代码开始加上自己的想法改成属于你的第一个定制渲染效果。