自研渲染引擎光照模型实战:从Lambert漫反射到Blinn-Phong高光
不知道你发现没有自己写一个渲染引擎几何变换、相机矩阵都跑通之后最兴奋也最受打击的一步往往是把第一盏灯放进场景。兴奋是因为终于能看到立体感了受打击是因为满屏的塑料质感亮的地方像刀切一样白暗的地方死黑一片转个视角高光还在乱跳。这不是参数没调够而是光照模型Lighting models本身没有选对、没有吃透。这篇文章以我搭过的一个简单引擎项目为主线只讲局部光照里最常用、最能稳定出效果的一整套做法为什么漫反射用Lambert高光用Blinn-Phong而不是Phong三种光源为什么写法完全不同衰减公式为什么长成那个样子材质参数又是怎么跟光照模型拧在一起的。如果你正卡在自己渲染引擎的“光照阶段”或者在做课程里的软件渲染器作业这篇应该能帮你把思路理顺。1. 为什么一开灯画面就翻车光照模型到底在解决什么问题1.1 一束光照到桌子方向、法线与视线的三角关系先问个最基础的问题我们为什么要写光照模型因为屏幕上某个像素的颜色本质上等于“这个像素对应的物体表面点最终有多少光进入了虚拟相机”。光从光源出发打到表面被表面反射其中一部分进入相机。这条路径上有三个向量非常关键光源方向L、表面法线N、视线方向V。光照模型就是用一条数学公式去近似“这三个方向关系下的反射能量”。很多人第一步就栽在“为什么同一个表面换个角度就变亮变暗”。拿一张纸放在台灯下纸面正对台灯时最亮倾斜一点就暗一些再斜就暗更多直到几乎平行时反射进眼睛的光就很少了。这个现象叫朗伯余弦定律它说的就是反射光强与cosθ成正比θ是法线N和光源方向L的夹角。直觉理解是同样一束光斜着打过来会摊开在更大的表面积上单位面积分到的能量变小所以看起来就变暗了。在代码里cosθ是用单位向量的点乘dotN, L算出来的结果范围在-1到1之间。1代表光源正对法线0代表平行扫过-1代表光从表面背面照过来。这个数值会被直接拿来缩放光源颜色所以严格来说漫反射的强度本质上就是一个“方向余弦响应”。理解这一层后面所有公式都顺了。1.2 局部光照与全局光照简单引擎的站位选择这里要澄清一个容易让新手焦虑的概念全局光照。现实世界里光在物体之间反复弹射白墙反射的光会打亮桌子暗面窗帘透进来的光会在房间角落铺上淡色这些都属于间接光照。想在引擎里完整模拟它需要做辐射度、路径追踪之类的重计算那不是“简单引擎”的第一站。我的建议很直接第一版引擎老老实实做局部光照Local Illumination只考虑光源直接照射到表面的能量把间接光用一个很小的环境光项近似掉。环境光项本身没有任何空间信息就是往最终颜色里加一个常数偏移比如ambient ambientStrength * ambientColor这个常数用来模拟“场景里所有物体互相反光后留下的最低亮度”。只要别调太大画面既不会死黑也不会丧失立体感。局部光照的每条公式都可解析、每个结果都可预期调试起来远比一上来就套全局光照方案舒服。等材质系统、多光源、相机管线都稳定了再考虑加环境贴图或者做一次简单的间接光完全不迟。这个站位决定了你的工作量边界简单引擎的目的不是物理正确而是视觉可控。视觉可控的意思是你调整任何一个参数都能大致猜到画面会发生什么。局部光照模型天然满足这个需求这也是它在实时渲染里活到今天的原因。2. 数学底座Lambert漫反射与Blinn-Phong高光的取舍逻辑2.1 Lambert漫反射一张纸被照亮的全部秘密把上一节的cosθ写成完整公式就是标准的Lambert漫反射diffuse albedo * lightColor * max(dot(N, L), 0.0)等号右边四项albedo是表面本身的颜色红砖墙偏红、白纸偏白lightColor是光源颜色dot(N, L)就是cosθmax(0, ...)的意思是当光线从表面背面射过来时不期望出现“负光照”直接把贡献归零。这个max看起来简单却是引擎里一堆“怪现象”的来源。很多新手舍不得写max或者写成了abs结果物体背面被染上奇怪的颜色。一个小技巧调试漫反射时直接把输出写成diffuse你能立刻看出哪些面是正面、哪些是背面非常直观。Lambert的局限也很明显它没有任何依赖视线方向V的项意味着不管从哪个角度观察同一个点的明暗完全一样。现实里哑光表面确实接近这种效果但塑料杯有高光手机屏幕有强烈反射金属表面更是明暗跳动剧烈。所以光靠Lambert画面会像涂了一层薄薄的哑光漆立体感有了质感还没有。2.2 Phong高光反射向量与视线方向的夹角为何不好用为了补上高光传统Phong模型引入一个想法镜面反射光会集中在“反射向量R”附近。先用反射公式算出RR 2 * dot(N, L) * N - L这个公式的本质是把入射方向L关于法线N做一次镜面翻转。然后看反射向量R和视线方向V的夹角φdot(R, V)越大说明视线越接近反射方向看到的高光越强。最终写成specular lightColor * specStrength * pow(max(dot(R, V), 0.0), shininess)shininess反光度控制高光的收缩程度取值小高光面积大、边界模糊取值大高光面积小像抛光金属。pow在这里的作用是把光谱“挤”向反射方向让高光有一个从亮到暗的锐利过渡。Phong在纸面上很漂亮实际写起来却有几个不舒服的地方。一是反射向量R多了几次运算二是当视线方向和反射方向夹角超过90度时dot(R, V)会跌成0高光一下子被“砍”掉边缘出现明显的不连续痕迹三是它对法线精度要求很高只要法线带一点噪声高光就会在物体表面抖来抖去。效果倒不是不能用但总觉得哪里被绊着。2.3 Blinn-Phong半程向量带来的稳定与效率于是就有了Blinn-Phong。它不再单独计算反射向量而是直接算半程向量H——光源方向和视线方向的中间方向H normalize(L V) specular lightColor * specStrength * pow(max(dot(N, H), 0.0), shininess)从几何上看H是“看向光源的方向”和“真正视线方向”之间的中间轴。当N和H重合时说明视线恰好盯着反射方向高光最强。这个变换省掉了反射向量计算还消除了Phong在大夹角下高光突变的问题所以后来几乎所有实时渲染引擎在非物理光照管线里都默认用Blinn-Phong。我做简单引擎时也直接选Blinn-Phong。这里的取舍逻辑很简单同样的效果更少的着色器指令、更稳定的边缘、更符合“手调参数看效果”的工作流。你可以把Blinn-Phong理解成“给Phong找了个更顺手的工具”它不追求物理真实但实时渲染性价比实在太高。下面这张表可以帮你快速对比三者定位模型依赖关键向量优势主要问题LambertN、L最简、稳定、无视线依赖没有高光表面像哑光PhongN、L、R、V高光方向直观反射向量计算多大角度有突变Blinn-PhongN、L、V、H高光稳定运算少高光形状与Phong略有差异3. 在渲染管线里落地从逐顶点光照到逐片元光照的经典选择3.1 Gouraud式逐顶点光照便宜但会留下马赫带光照模型公式写出来不复杂真正决定画面质量的是你把它放在渲染管线的哪个阶段执行。最早的实用方案是逐顶点光照顶点着色器里算好光照颜色让插值器把颜色在三角形内部平滑过渡。这个方法后来被称为Gouraud Shading优点是明显每个顶点只算一次三角形内部只有一次线性插值性能极好非常适合软件渲染器。它的缺点也很致命。光照模型往往是非线性函数尤其是pow高光它是“挤”出来的。你在顶点之间做线性插值插值的却是已经被pow挤压过的数值三角形内部颜色会偏离真实曲线于是高光经常在三角形顶点上聚成一团光斑三角形内部反而暗下去。更糟的是相邻三角形各自不同网格上会浮现一圈圈“马赫带”——大脑会自动加强明暗突变处的边缘感让画面显得脏而破碎。我第一版软件渲染器就踩过这个坑几何正确、法线正确但渲染出的球面上总有一道道明暗竖纹。查明原因后我把光照从顶点阶段挪到片元阶段竖纹立刻消失。这是简单引擎里最值得认真体会的一次迁移它比任何理论解释都直观。3.2 逐片元光照的最小实现流程所谓逐片元光照就是在片元着色器阶段对每个屏幕像素执行完整的光照模型计算。这个流程写顺之后后续加阴影、加多光源都只是往同一框架里塞东西。最小实现可以拆成五步顶点着色器输出世界坐标、法线以及纹理坐标等其他需要的属性。片元着色器拿到插值后的世界坐标和法线立刻归一化。计算光源方向L、视线方向V、半程向量H。分别计算漫反射项和高光项。环境光、漫反射、高光相加输出到颜色缓冲区。对应到GLSL风格的着色器核心代码大概是这个模样// 片元着色器局部示意 vec3 N normalize(vNormal); vec3 L normalize(uLightPos - vWorldPos); vec3 V normalize(uCameraPos - vWorldPos); vec3 H normalize(L V); float ndotl max(dot(N, L), 0.0); float ndoth max(dot(N, H), 0.0); vec3 diffuseColor uAlbedo * uLightColor * ndotl; vec3 specularColor uLightColor * uSpecStrength * pow(ndoth, uShininess); vec3 finalColor uAmbient diffuseColor specularColor;很多人在这个阶段忘记第一行给法线归一化。顶点法线经过光栅化插值后长度通常不再是1直接拿去点乘结果会被整体放缩画面就出现一种“忽明忽暗”的脏感。归一化看着只多一条语句却是整个光照调试里优先级最高的习惯。3.3 给着色器立几条朴素规矩点乘、归一化与分支写多了光照着色器我给自己总结了几条很朴素的规矩简单引擎阶段完全够用需要参与点乘的向量统一先normalize。不要只在公式里写一次要确保所有方向向量长度都是1。能合并的向量计算不要拆开算。半程向量H是L和V同时参与的结果一次normalize就够别分别归一化L和V再相加那样等于多花一组运算。片元着色器里尽量避免动态分支。同一三角形内部的像素走不同分支会让GPU并行效率变差。像if (ndotl 0)这种更推荐直接用max替掉把分支翻译成数学运算。pow的底数永远夹到0以上。底数一旦为负在不同硬件上结果可能完全不同调试时最怕这种平台差异。这四条不是教条是从一次次画面异常里总结的。尤其是pow底数变负几乎每个写光照模型的人都遇到过但很少有人第一时间往底数上想。4. 从单灯到多灯光源类型差异与衰减策略4.1 平行光、点光源、聚光灯参数差异就是公式差异引擎里最常见的三种光源类型参数不一样光照公式写法也不一样。很多人以为是三种算法其实它们只是同一套光照模型换了输入参数平行光只有方向没有位置和衰减。场景里所有表面的L方向都是同一个向量太阳光就是典型。点光源有位置L方向从表面指向光源位置随距离变化并且要做距离衰减。聚光灯在点光源基础上多一个主轴方向和锥角限制偏离主轴越远光照越弱锥角之外完全不照亮。对应到计算上的差异可以写得很精简// 方向向量与衰减示意 vec3 L useDirectional ? normalize(uLightDir) : normalize(uLightPos - vWorldPos); float attenuation useDirectional ? 1.0 : 1.0 / (1.0 kLinear * d kQuadratic * d * d);聚光灯的锥角衰减通常用主轴方向S和L的反向之间的点乘近似dot(-L, S)越接近1说明片元越在锥角中心再配合内外两个阈值做平滑过渡就能做出手电筒那种“中心亮、边缘柔”的效果。4.2 物理衰减与视觉衰减平方反比为什么不直接可用从物理角度点光源辐射到单位面积的能量按距离平方反比衰减也就是1/d²。但直接在光照模型里用它会出现很尴尬的画面光源附近亮到爆稍微走远几步就黑到看不见动态范围拉太大显示器根本装不下。所以实战里更常用改良形式attenuation 1.0 / (1.0 kLinear * d kQuadratic * d * d)分母里的常数项避免距离为0时除零爆炸线性项提供一条平滑骨架二次项保留一部分物理特征。你调kLinear和kQuadratic的过程本质上是在“物理正确”和“显示舒适”之间找平衡。我的经验是kLinear给0.1到0.2kQuadratic给0.01到0.03然后按场景里灯光的影响范围微调别一上来就照搬教科书的纯平方反比。还有衰减截断问题。理论上点光源影响整个场景但实际引擎里不可能让一盏灯遍历所有物体。简单做法是给点光源一个最大影响半径超出半径直接算衰减为0或者做一个平滑渐变。这个“范围外不贡献”的假设是多光源性能优化的起点。4.3 简单引擎的多光源方案固定上限比无限光源更实用多光源怎么塞进渲染流程最直白的方式是给着色器传一个光源数组循环累加每个光源的贡献。但动态循环访问数组在一些图形API里并不高效所以常见做法是设置一个相对稳定的光源上限比如4到8个对每个片元执行固定次数的累加。vec3 total vec3(0.0); for (int i 0; i MAX_LIGHTS; i) { if (i uLightCount) break; total accumulateLight(uLights[i], ...); }这种写法胜在实现简单调整灯数量只需改一个uniform整个场景渲染代码不用变。代价是就算场景里只有一盏灯也会循环最大次数浪费一点算力。简单引擎完全接受这种浪费因为正确性和可维护性比这点性能更值钱。等以后想优化再考虑按光源影响半径裁剪物体、用模板标记受影响的区域或者干脆做延迟渲染把光照循环挪到屏幕空间。这些进阶手段有一个共同前提你已经在某个固定上限的多光源框架里把光照逻辑调通了。5. 材质参数与光照模型的连接调出塑料、金属与旧墙面5.1 三个旋钮的直觉反照率、高光强度与反光度光照模型本身是通用的它不知道你画的是塑料还是一堵墙。材质信息通过参数喂进来在Blinn-Phong里最核心的只有三个值反照率Albedo / Diffuse Color表面“本色”决定漫反射项颜色。红墙反照率偏红白纸接近纯白。高光强度Specular Strength决定高光项整体强弱。塑料高光清晰但强度不高镜面金属高光极强甚至能压过漫反射。反光度Shininess决定高光点大小和锐度。数值低高光像磨砂扩散数值高高光像镜面聚焦。这三个值组合起来的“性格”非常鲜明。塑料制品通常是中等反照率、中等高光强度、中等反光度抛光金属是低反照率、高高光强度、高反光度旧墙面就是高反照率、接近零的高光强度。把这些配方记住不用看参考图也能调出大致质感。需要说明的是Blinn-Phong里这些参数不是物理量只是“看起来像”的视觉参数。真实塑料的微表面结构会让高光随观察角度变化更复杂但在简单模型里一个shininess就能模拟得七七八八。别被“不物理”的说法吓住视觉上可控就是简单引擎最大的合格标准。5.2 参数调试的对照实验从基准白球开始拆项调材质参数我最推荐的方式是“拆项对照”。先在场景中心放一个标准圆球给它中性反照率比如0.5灰度、低高光强度、中等反光度场景调成单点光源背景尽量干净。然后每次只改一个参数观察变化。更系统的做法是在调试阶段把最终颜色拆成三个可切换的输出// 调试用输出开关 finalColor uDebugAmbient ? ambient : finalColor; finalColor uDebugDiffuse ? diffuse : finalColor; finalColor uDebugSpecular ? specular : finalColor; finalColor uDebugComposite ? ambient diffuse specular : finalColor;只看漫反射项能立刻发现法线方向对不对、光源方向是否反了只看高光项能迅速看出shininess把高光挤到多大只看合成结果才能评价三项配比是否舒服。我用这个办法排查过好几次“画面像塑料”的问题最后都发现是某个单项太强而不是整套模型出错。一个小经验环境光项别调太高。很多新手为了让暗部不黑死把环境光直接调到画面亮度的两三成结果整个画面灰蒙蒙漫反射立体感全被盖掉。环境光只是“暗部最低亮度”的托底一般调到漫反射最高亮的10%到20%就够它不是主光源的替代品。5.3 旧光照模型的边界什么时候该考虑PBRBlinn-Phong用起来顺手但边界也要说清楚。最明显的痛点是能量不守恒反射出去的光能可能超过入射光能高光项和漫反射项各自独立某些角度颜色亮得不正常。再加上它用一个标量shininess描述高光没法区分金属和非金属的反射特性差异粗糙表面的微表面变化更是表现不了。当你发现这些局限真正影响画面质量时就是换基于物理的光照模型PBR的时候。PBR的核心不是一两个公式替换而是一整套工作流金属度、粗糙度、法线贴图、能量守恒的BRDF。但无论它多“物理”踩坑时的调试手段仍然绕不开“拆项对照”这套方法论所以先把Blinn-Phong吃透再上PBR路会顺很多。6. 光照调试实录发黑、过曝与接缝闪烁的完整排查链路6.1 法线为什么会指向反方向从模型数据到变换矩阵光照画面最烦人的问题之一就是“怎么调都发黑”。如果只有部分表面发黑其他表面正常第一个怀疑对象是法线。法线反了会让dot(N, L)变负数经过max截断后输出0这个面就彻底失去漫反射贡献只剩环境光托底。常见的法线错误来源有三个。一是模型导出时顶点法线没有统一朝向网格里混着少量内翻的面二是法线数据的符号在某个转换环节被写错三是变换矩阵带非均匀缩放时直接用模型变换矩阵乘以法线导致法线方向被错误扭曲。第三种最隐蔽因为画面看起来“只是有点怪”但功能上又好像都对。正确做法是利用法线变换矩阵也就是模型变换矩阵的逆转置矩阵。因为非均匀缩放会同时作用在旋转和缩放上普通矩阵会把法线拉变形逆转置矩阵能保证变换后的法线仍然垂直于表面。简单引擎如果只做均匀缩放这个问题不明显但一旦你把物体拉伸成长方体、压扁成圆盘就一定躲不过。6.2 颜色空间暗部发蓝、亮部过曝的“脏”来自哪里另一种让人头疼的问题是参数明明没动整张图却“脏”。暗部蒙一层灰蓝亮部动不动白到过曝中间调透着一股浑浊感。这通常不是光照模型的问题而是颜色空间链路没打通。显示器和彩色图像普遍以sRGB颜色空间工作它带大约2.2的伽马曲线。如果你的渲染管线把线性颜色值直接输出到帧缓冲却没有做线性到sRGB的转换看到的画面就会比数学推导结果偏暗、偏色。反过来如果读取纹理时没把sRGB纹理转回线性空间再做光照混合所有线性运算就都在非线性空间里进行结果自然就“脏”了。简单引擎的正确姿势是参与光照计算的数值全部保持在线性空间最终输出到帧缓冲前做一次sRGB编码。很多图形API可以靠帧缓冲格式自动完成也可以在着色器末尾手动做。我建议哪怕引擎现在还只画纯色物体也先把颜色空间链路打通后面加纹理、雾效、环境光时这个问题会越积越深到时候再回头改就痛苦了。6.3 浮点精度与接缝闪烁大世界坐标下的灵异事件第三个经典坑是接缝闪烁。两个共面的三角形明明应该连续却在拼接处出现一亮一暗的条纹仔细看还会随距离变化跳动。这类问题通常指向浮点精度。原因在于当顶点世界坐标离原点很远时float的尾数精度不足以记录足够细的差值。两个顶点之间的短边可能被舍入成同一个值插值出的法线或位置便出现意外的跳跃反映到画面上就是接缝处的高光、颜色周期性闪烁。排查链路很有规律先看法线精度问题把法线直接输出颜色观察再看深度精度问题调整远近裁剪面的比例最后才想到顶点坐标本身的float精度。解决方案分几档简单粗暴的做法是加大精度或者把场景原点移到需要高精度的物体附近更系统的做法是在渲染时以相机为原点把顶点坐标先变换到相对相机的空间再参与插值这样数值大小始终被控制在合理范围内。我自己在把场景切换到相对相机坐标之后这类接缝闪烁一直没有再犯算是用起来最省心的一种方案。