1. 光照模型在Shader里的地位为什么你绕不开它做渲染的人上手第一个正经写的Shader多半是个带光照的漫反射效果。因为光照决定了物体是否“立体”也决定了场景真实感的底线。把光照模型吃透你再看引擎自带的Standard、Lit这些表面着色器就不觉得是黑盒了调参数也会更有底。这套内容解决的是这样一个问题在Shader里我们如何用数学方式模拟光打到物体表面、再反射到人眼的过程。它能干的事很明确——区分出物体的明暗、高光、环境遮挡、边缘光等视觉特征。适合正在学Shader的初学者、刚入行做Unity或Unreal的TA、以及想系统梳理渲染基础知识的程序向美术同学。光照模型不是一个孤立的概念。你写一个漫反射Diffuse本质是在算“光方向与法线方向的点乘”写高光Specular本质是算“反射方向与视线方向的接近程度”。所有眼花缭乱的效果最终都落在几何向量运算和少量经验参数上。早期游戏里的物体看起来“塑料感”重正是因为只用了一级漫反射加Blinn高光没做能量守恒、没做多级衰减、没有环境光叠加。后来大家发现只要把光照模型中的每一项都拆开看得到的结果就完全不一样。这也是这篇要重点做的事把每个项讲透然后手写一遍记录每个参数的视觉影响。我始终认为学Shader光照模型最有效的方式不是背公式而是做实验——改一个参数、看一次效果、记下变化。下面从最经典的两个模型入手再把它们串成实战Shader代码。2. 经典光照模型的核心逻辑与选型思路在做任何Shader实现之前先要搞清楚“光”是怎么被模拟的。实时渲染里用的光照模型几乎都不是物理正确的而是在“算得动”和“看得像”之间找平衡的经验模型。理解这一点很重要否则你容易陷入“参数越多越好、公式越复杂越牛”的误区优化起来却不知从哪一刀切。2.1 光照模型解决的三个核心问题一个物体在光下呈现什么样子主要由三个问题决定。第一个问题是“多少光被表面吸收”。这个主要由材质本身的颜色Albedo决定光打到物体一部分被吸收、一部分被反射反射的比例决定了物体固有色。第二个问题是“反射光往哪里走”。表面如果粗糙反射光向四面八方散开就是漫反射表面光滑反射光集中在特定方向就是高光。这个由法线方向、视线方向、光方向三者的几何关系共同决定。第三个问题是“环境里还有什么光在影响物体”。真实世界里物体被天空、周围墙面、地面反射光照亮不只有单一方向光。实时渲染为了性能通常用环境光项、球谐光照、反射探针等来近似。Shader里的光照模型就是对这三个问题的数学回答。每个模型给出不同的回答方式视觉结果也因此不同。2.2 从Lambert到Blinn-Phong按需选择模型你肯定听过这两个名字Lambert漫反射模型和Blinn-Phong高光模型。即便后来的PBR基于物理渲染席卷了主流引擎这两个经典模型仍然有意义因为它们是一切的基础也是性能预警时的备用方案。Lambert模型极其简单反射亮度等于光照颜色乘表面颜色再乘法线与光方向的点乘结果再乘一个强度系数。它的优点是计算量小缺点是没有高光物体像磨砂表面纯靠固有色和明暗变化表达体积。Blinn-Phong模型在Lambert基础上多了一项高光用半角向量光方向加视线方向再归一化点乘法线再取指数次方。指数值越大高光越集中。它的优点是效果直观、参数好调缺点是物理上不保真高光不会随粗糙度变化而改变强度比例。选型时要看场景需求。如果做低精度模型、风格化渲染、卡通ShaderLambert加分级色带就够了如果做普通质感的物件展示、简易角色渲染Blinn-Phong是非常稳妥的选择如果要做高品质的材质表现再上PBR那套微表面模型。2.3 我为什么建议从Blinn-Phong做入门实战我见过不少学习者直接啃PBR代码结果被法线分布函数、几何遮蔽函数、菲涅尔项搞到劝退。不是说PBR不该学而是应该先把Blinn-Phong写明白因为它的每一行代码都对应一个直观的视觉要素调试起来非常方便。Blinn-Phong的全部变量只有五个表面法线、光方向、视线方向、漫反射颜色、高光颜色与强度。把它写到Shader里大概三十行核心代码就能出效果。这让你有余力观察每个参数的视觉影响而不是陷在数学推导里。另外一个理由是Blinn-Phong的“半角向量”思想直接沿用到PBR里。GGX法线分布函数虽然复杂但用的向量组合方式与Blinn-Phong高度一致。你把Blinn-Phong吃透后面看PBR的代码结构就不会觉得陌生。3. 实操前置Shader结构拆解与光照变量来源这一节开始进入动手阶段。先不着急贴完整代码把Shader的结构和光照相关变量的来源搞清楚否则你抄完代码也不知道为什么这么写。很多人写光照Shader最常犯的错就是不知道哪些数据是顶点着色器算出来的、哪些是片元着色器里才算的导致光照效果位置不对或者出现奇怪的断层。3.1 Unity Shader的基本结构与光照函数切入点用Unity写一个光照Shader通常是Surface Shader或Unlit Shader加光照计算。入门阶段我推荐用Surface Shader因为引擎自动帮你处理了光照数据的传递你只需要写一个光照函数和几个变量。这里的“表面着色器”是Unity的封装层不是标准意义上的光照模型。它会自动生成多个Pass处理前向渲染、延迟渲染等路径差异让你专注于光照计算本身。Surface Shader的核心结构是Properties块声明材质参数SubShader里写Tags和CGPROGRAM然后在#pragma surface 指令中指定表面函数和光照函数。光照函数是核心它接收一个Input结构和一个SurfaceOutput结构返回颜色值。简单理解SurfaceOutput里你填好漫反射色、高光色、法线、光泽度光照函数负责把这些数据结合光方向算成最终颜色。如果你直接用Unlit Shader就得自己写顶点/片元着色器在顶点里把法线、顶点坐标从模型空间转到世界空间然后通过插值传到片元。这个过程也不难但需要理解空间变换至少知道UnityObjectToWorldNormal这个函数是在干什么。3.2 法线、光方向、视线方向的获取与归一化在Shader代码里法线是最重要的向量。表面法线通常来自模型的顶点数据在顶点着色器里用UnityObjectToWorldNormal把它从模型空间转换到世界空间然后传到片元。Unity内置变量里_WorldSpaceLightPos0表示当前主光源的方向或位置。对于平行光它的w分量是0直接用xyz作为方向向量对于点光源则需要用顶点位置减去光源位置来得到光方向向量。视线方向是另一个关键向量它等于摄像机位置减去当前像素的世界坐标。Unity里用_WorldSpaceCameraPos表示摄像机位置片元世界里坐标就是输入结构里插值得到的worldPos。把这两个向量相减并归一化就得到视线方向。在实际代码里所有的方向向量都必须归一化。即使你在数学上知道某两个向量的夹角余弦等于点乘但如果向量的长度不是1点乘结果会多出一个缩放因子光照就会出现大面积过曝或过暗。这是个非常隐蔽的问题有时一个模型看不太出来换成复杂模型就坏掉了。3.3 一张图在脑中建立光照坐标系在写代码之前建议你在脑子里建立这样一个画面。物体表面上一点竖直方向是法线N。从该点指向光源的方向是L从该点指向摄像机的是V。光方向L与法线N的夹角决定了这一点的漫反射亮暗——夹角越大越暗。半角向量H是(LV)归一化后的结果H与N的夹角决定了高光区域的大小与位置。这个画面一定要牢牢记在心里。你可以用左手比划一下拇指是法线食指是光方向中指是视线方向三个手指一夹中间那个方向大致就是半角向量。每次写光照Shader时先在脑子里过一遍这个手势哪个向量点乘哪个、结果放在哪里就不会混乱。4. 完整实现手写一个带环境光漫反射高光的Blinn-Phong Shader现在进入正题。我会给你一份可以在Unity里直接用的代码然后逐段讲解。这份Shader的实现思路是在片元着色器里分别计算环境光、漫反射、高光三项最后叠加输出。为了后续调参方便所有强度参数都在Properties面板里暴露出来。4.1 完整的Shader代码与参数说明下面这段代码基于Surface Shader书写方便你快速跑通效果同时完整展示了Blinn-Phong的数学逻辑。Shader Custom/BlinnPhongTutorial { Properties { _MainTex (Albedo (RGB), 2D) white {} _Gloss (Gloss, Range(8, 256)) 32 _SpecColor (Specular Color, Color) (1, 1, 1, 1) _AmbientIntensity (Ambient Intensity, Range(0, 1)) 0.3 } SubShader { Tags { RenderTypeOpaque } CGPROGRAM #pragma surface surf BlinnPhongTutorial fullforwardshadows sampler2D _MainTex; float _Gloss; fixed4 _SpecColor; float _AmbientIntensity; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { fixed4 tex tex2D(_MainTex, IN.uv_MainTex); o.Albedo tex.rgb; o.Specular _Gloss; o.Gloss _Gloss; } fixed4 LightingBlinnPhongTutorial (SurfaceOutput s, fixed3 lightDir, fixed3 viewDir, fixed atten) { // 环境光直接用 float3 ambient unity_AmbientSky.rgb * _AmbientIntensity; // 漫反射法线与光方向点乘夹在0到1 float ndotl saturate(dot(s.Normal, lightDir)); float3 diffuse s.Albedo * _LightColor0.rgb * ndotl; // 高光半角向量 float3 halfDir normalize(lightDir viewDir); float ndoth saturate(dot(s.Normal, halfDir)); float spec pow(ndoth, s.Specular * 256); float3 specular _SpecColor.rgb * _LightColor0.rgb * spec; // 衰减 float3 final ambient (diffuse specular) * atten; return fixed4(final, 1.0); } ENDCG } FallBack Diffuse }4.2 为什么这样设计光照函数签名你看上面的光照函数名字是LightingBlinnPhongTutorialUnity会根据这个函数名自动匹配#pragmasurface后面的光照模型名。函数接收四个参数SurfaceOutput、光方向、视线方向、衰减系数。这些参数都由引擎在渲染管线中算好传入你只需要直接使用。一个值得注意的点是s.Specular和s.Gloss的分工。SurfaceOutput结构里有Specular和Gloss两个字段。Gloss我存的是同一个值Specular也赋了同样的值——这里是想演示两个字段的赋值位置。实际使用时Specular可以存高光颜色强度或粗糙度信息Gloss可以存另一个维度的参数可按需调整。衰减系数atten的作用是处理点光源和聚光灯的衰减。平行光时atten通常为1点光源则是一个随距离衰减的值。如果你忽略它点光源照射时会出现光源处过度曝亮、远处立刻变黑的问题。4.3 参数计算过程从move到视觉变化我拿_Gloss这个参数实测了一下它的视觉影响。_Gloss为8时高光范围很大像塑胶表面为32时高光集中像有一定光泽的漆面为128时高光缩成一个小点接近镜面反射效果。这是因为pow函数把半角向量的点乘结果做了幂次放大——底数越接近1即法线与半角越接近幂次放大后保留的值越大底数稍小一点经过高幂次后快速归于0。_AmbientIntensity参数则控制整体暗部亮度。调为0时背光面完全是黑的对比度强但有死黑风险调到0.5时暗部能看清轮廓但缺少层次。通常放在0.2到0.35之间比较自然因为在Unity里环境光默认值已经比较暗再加上系数很容易发灰。还有一个容易忽略的点是saturate函数的使用。dot法线与光方向的结果可能是负数即光从表面背面打来这时漫反射应为0而不是负数。saturate把输入限制到0到1之间就避免了这个情况。高光的点乘同理。如果不用saturate物体背光面可能出现黑斑或颜色反转的污染看起来非常脏。4.4 在场景里验证效果的实操步骤把这个Shader挂到模型上之前先建一个简单的测试场景。放一个球体、一个立方体加一盏平行光把光源角度调成45度俯视。场景里不要放太多其他物体避免反射探针和实时光照干扰判断。场景准备好后创建一个新材质把Shader选到Custom/BlinnPhongTutorial赋给球体。在Game视图里旋转光源观察球体的明暗变化。重点看三个地方背光面的暗部是否有死黑高光点是否随光源旋转而移动高光的颜色是否能通过_SpecColor改变。我再给你一个确认法线方向的经验给模型加一个Normal可视化Shader或者使用场景里的调试视图把法线颜色输出成RGB偏色。这样你能一眼看出模型的法线是否翻转、是否均匀避免光照出现奇怪的条纹。很多时候光照问题不是代码错了而是模型的法线数据就是坏的。5. 常见问题与排查技巧实录写光照Shader时遇到的问题一半是数学细节一半是管线理解。这里把最常见的几个问题和排查方法整理出来都是我实操中反复踩过的坑。哪怕你的代码逻辑完全正确也可能会被这些问题坑到。5.1 光斑偏移、暗部错乱的排查思路最常见的问题高光不在光照射的位置上或者暗部出现奇怪的色带。先别急着怀疑代码第一步要检查的是法线有没有归一化、有没有在正确的空间里。如果法线在模型空间、光方向在世界空间两者点乘结果就是错的。另一个高频原因是没有使用saturate处理点乘结果。负数的点乘直接进入pow计算可能导致pow接收负数报错在部分平台行为不一致或者渲染出脏脏的颜色。你可以在片元里加一句调试输出把ndotl直接作为颜色输出看看明暗过渡是否连续。如果ndotl出现负数你会直接看到视野内某些区域颜色明显异常。还有一个很隐蔽的情况在顶点着色器里归一化的法线经过插值传到片元后长度已经不是1了。此时需要在片元里重新归一化。很多人写Surface Shader时没注意这个问题因为SurfaceOutput里的法线是引擎帮你处理过的。但如果你写自己的顶点/片元Shader记得在片元里把normal归一化。5.2 高光过曝或完全不显示的原因分析高光完全消失最常见原因是_Gloss值过高加上模型面数过少。pow函数对精度要求很高如果_Gloss开到512、法线与半角向量的点乘结果在插值后变化剧烈高光可能只出现在极少几个像素上视觉上几乎看不到。这种时候不是代码坏了是参数过激。高光过曝则通常是因为高光项没有乘衰减项或者_SpecColor设置得过大。记得把高光项和漫反射项都乘atten。另外你在Surface Shader里写的光照函数返回的是一个fixed4颜色但不会自动做ToneMapping所以叠加出来的颜色可能超过1出现曝白。这种结果在某些平台上看是正常的在HDR环境下也正常但在普通LDR显示下会泛白。建议开启场景里的PostProcessing Bloom或者把光源强度调低一些再观察。5.3 对光照模型做单元验证的调试技巧我强烈推荐一个调试思路把光照模型拆成三份分别输出成颜色。先只输出环境光再只输出漫反射再只输出高光。你分别看这三个结果是否正常最后再合并。这样做的好处是定位问题极快。实际操作时可以临时把光照函数里return的值改成不同组合比如“return fixed4(diffuse, 1.0)”“return fixed4(specular, 1.0)”“return fixed4(ambient, 1.0)”。在Game视图里切着看。如果你发现漫反射正常、高光异常那问题一定出在半角向量或pow部分如果环境光正常、漫反射异常那查光方向与法向量的点乘。还有一个工具层面的建议开启Unity的Frame Debugger选中这个材质对应的Draw Call查看该Pass的着色器属性值、uniform变量的具体数值。这样你能直接看到传入的光方向、摄像机位置是否正常不用靠猜。5.4 常见问题排查速查表现象可能原因排查方法整体发灰没有明暗对比环境光过强或漫反射未乘_LightColor0降低_AmbientIntensity检查_LightColor0值背光面死黑_AmbientIntensity0或环境光无贡献调大_AmbientIntensity检查场景环境光设置高光完全不显示_Gloss值过大、或pow底数过小把_Gloss降到16再试确认ndoth为正值高光随视角抖动闪烁法线未归一化或高光幂次过高导致锯齿片元里重新归一化法线降低_Gloss或开启抗锯齿模型表面出现黑色条纹法线方向翻转或错误用调试Shader输出法线颜色检查法线方向点光源边缘突变未乘atten衰减确认光照函数里使用atten6. 进阶方向从Blinn-Phong到PBR的衔接思路写完Blinn-Phong你其实已经掌握了Shader光照模型的大半个框架。接下来的方向是把这套模型替换成PBR的微表面模型或者做特殊风格的光照控制。这条进阶路走起来会顺很多因为你已经知道向量点乘的本质、pow参数的作用、光照叠加的方式。PBR里的核心变化是不再用Blinn-Phong的经验高光pow公式而是用法线分布函数NDF来描述微表面的法线统计分布。最常用的是GGX分布它的公式里有一个粗糙度参数控制了高光扩散的形态。它和Blinn-Phong的_Gloss有类似作用但数学更精细高光形状更接近真实材质。另一个大变化是引入了能量守恒反射掉的光不能大于入射光。Blinn-Phong的公式没有这项约束所以可能出现高光很强、同时漫反射也很强的过曝情况。PBR里就用菲涅尔项和几何遮蔽项来调节光能分配。我给一个具体的学习路径先看完Blinn-Phong的代码接着去看Unity内置的Standard Shader的BRDF函数实现源码在官网下载包里有。从BRSDF1函数看起先忽略复杂的传参结构只看里面漫反射项怎么写、高光项怎么写、菲涅尔项怎么写。你会发现很多向量运算的模式和你刚写的Blinn-Phong完全一致。然后尝试把Blinn-Phong的高光项替换成GGX的NDF函数做一次最小化改造。这项工程不需要一步到位。你可以先保留Lambert漫反射只替换高光项做成一个半PBR风格的效果。效果可能不物理正确但视觉表现会有很大变化你也会真切理解粗糙度、金属度这些参数的作用。如果做风格化渲染光照模型的改造就更自由了。比如把漫反射项在某个阈值处做阶跃就得到卡通渲染的明暗二分效果再叠一层边缘光利用法线与视线方向点乘的结果反推朝向边缘的程度角色轮廓感会好很多。核心思想仍然不变改造漫反射项、改造高光项、改造环境光项。7. 移动端和性能优化的几个关键心得做Shader只写对还不够还要考虑跑在谁的设备上。移动端的GPU架构、带宽、运算能力都有限制。光照模型这部分优化空间其实很大但前提是你分得清“算得太多”和“用得不合理”的区别。移动端光照模型尽量不要超过三层运算一层漫反射、一层高光、一层环境光各一次向量归一化和点乘加上一次pow、一次采纹理。这个量级在绝大多数手机上都能流畅跑。如果加动态阴影、多个实时光源GPU压力会成倍增长。经验上有一个重要规律向量归一化非常昂贵。它能避免就避免能合并就合并。有些Shader里为了美观写了大量normalize结果每帧多个光源都重新计算一遍性能浪费严重。如果两个向量需要组合先组合再用一次normalize而不是分别normalize再相加。半角向量的计算就受益于这个规律先加光方向和视线方向再归一化。另一个优化点是分辨率有关的计算。片元数量由屏幕分辨率决定任何片元里的数学运算都在乘以百万像素这个量级。所以能搬到顶点着色器里算的尽量搬过去。比如高光的点乘、半角向量如果视角和光源不变完全可以在顶点阶段算出来再插值到片元。当然这会牺牲一点精度但对于移动端来说这点误差通常可接受。最后材质参数的数量也会影响性能。并不是每个材质都要暴露七八个参数。建议把常用的三五参数设成固定值只暴露真正需要调节的。Shader变体数量膨胀会造成打包体积增大和加载变慢。所有你在Properties里看到的参数最终都会变成GPU uniform太多的分支和参数排列组合会产生大量变体得不偿失。8. 如何把光照模型融进你自己的渲染管线你可能不只是在Unity里写Surface Shader也可能在Unreal、自研引擎、或者WebGL里写Shader。光照模型的数学知识是通用的差异只在API和框架封装层。所以最后一个话题是教你抽象一套自己的光照模型库方便跨项目复用。建议把光照模型函数单独抽成一个公共头文件或cginc文件里面只放纯数学函数。例如可以写成LightingBlinnPhong(float3 normal, float3 lightDir, float3 viewDir, float3 lightColor, float attenuation, float gloss)这样的纯函数输入向量和参数输出颜色。Shader主文件里只负责数据准备和调用。这套抽象方式能让你在一个项目里试不同光照模型时不用大动干戈地带上一堆依赖也不用在材质面板里同时让两个高光参数露出。依赖少调试快跨项目迁移时也轻松。另外一个建议是至少建一个灯光调试场景。场景里放一个标准球、一个标准立方体、一块标准地面光源角度固定材质参数默认。每次写新Shader都先放在这个场景里跑一遍观察漫反射、高光、环境光的基准表现。这个习惯能帮你快速判断新模型是否符合预期而不是每次重新搭场景浪费时间。我自己的习惯是这个调试场景里固定放六个球一个白球、一个黑球、一个亮金属球、一个暗金属球、一个粗糙球、一个光滑球。每个球分别显示不同材质参数的效果一跑就清楚这个光照模型在各类表面上的表现。这个方法也分享给你很实用。9. 最后再分享一点实操中的个人体会做了这么多年的渲染相关的工作回头看最基础的光照模型反而觉得它是整个渲染知识体系里最珍贵的那部分。不是因为它的数学有多深而是因为它把渲染管线中所有关键向量串了一遍让人真正理解了“光从哪来、反射到哪去、眼睛怎么看”。我见过很多人一开始就追最新技术拿了一堆PBR笔记但实际改起参数来经常犯错原因就是对经典光照模型里的向量关系不够熟悉。而另外一些同事基础打得很扎实哪怕换引擎、换渲染管线也能迅速写出符合新框架的Shader来。差异恰恰出在对这些基础逻辑的理解深度上。如果这篇内容对你有些帮助建议你不要只停留在读一遍。打开Unity把代码贴进去每个参数从头到尾改一遍记录每一次视觉变化。这个过程大概花掉你一个小时但收获比看十篇教程都大。光照模型这东西动手试过才能真正长在自己身上。
阅读完成 · 觉得有帮助?