资讯动态

光照模型从零实现:Blinn-Phong与Shader渲染实战

发布时间:2026/10/17 15:00:04 来源:尧图企业网站定制
最近在整理自己写的那个小引擎正好做到 Lighting Materials 这一块。光照模型这个名字听起来很唬人但拆开看其实就是一件事怎么用数学公式告诉渲染管线一个表面在某个光源下应该呈现出什么颜色。这篇文章不打算讲太多渲染方程的理论推导而是从“我自己从零实现一个光照模块”的角度把设计思路、公式取舍、Shader 落地步骤以及调试过程中踩过的坑统统记录下来给同样在写玩具引擎或者刚入门图形学的朋友做个参考。这个模块能解决的问题很具体场景里放一个点光源物体表面为什么有的地方亮、有的地方暗为什么有的材质有高光、有的完全没有为什么塑料球和磨砂墙面看起来质感完全不同如果你的引擎目前只有纯色物体或者光照效果总是一团糟那这篇文章正好适合你。我默认你已经有最基本的 OpenGL/DirectX 绘制三角形经验至少能画出一个带纹理的立方体接下来的内容会更顺畅。1. 光照模型的设计思路从磨砂墙到塑料球的视觉密码1.1 为什么先把光照模型模块单独拎出来我在架构这个小引擎的时候刻意把光照和材质设计成相对独立的一块而不是直接揉进渲染器里。原因很实际光照模型本质上是纯函数输入是法线、视角方向、光源方向、材质参数输出是颜色。它不依赖具体的网格数据也不关心模型是三角形还是四边形。把这个纯函数独立出来后续换成 PBR 流程也只需要替换内部的实现接口完全不用动。我当时给自己定了一个最低目标不追求物理正确先追求“看起来合理”。“看起来合理”意味着几个基本共识必须满足。光源方向垂直照到表面时表面应该最亮斜着照的时候亮度应该平滑下降。直视高光区域时应该能看到比漫反射更亮的光斑而且物体表面越光滑这个光斑越小越亮。环境光不能是纯贴图死黑至少要给背光面一点基础亮度。这几个需求加在一起经典的 Blinn-Phong 模型几乎就是标准答案既有足够的视觉表现力又不会把一个下午变成一周。1.2 三个经典模型的取舍Lambert、Phong 与 Blinn-Phong实现的时候有一个绕不开的选择题用 Lambert、Phong 还是 Blinn-Phong。我一开始把三个都写了一遍对比效果最终选择 Blinn-Phong 作为主模型原因后面会细说。Lambert 模型只计算漫反射公式是max(dot(N, L), 0) * baseColor。它没有高光适合表现粗糙表面比如墙面、泥土、布料。如果你做的是那种低多边形风格游戏全场景用 Lambert 反而干净利落。Phong 模型在 Lambert 基础上加了镜面高光用反射光线和视角方向的点积来计算。它的问题在于反射光线的计算要额外花费一个 reflect 指令而且在视线与反射方向夹角较大时高光会呈现出不自然的拉伸。Blinn-Phong 模型计算量更少效果更好控制。Blinn-Phong 的核心变化是引入了半程向量H normalize(L V)用dot(N, H)代替 Phong 里的dot(R, V)。半程向量的几何意义是如果这个表面的法线正好指向半程向量方向那么反射光线会恰好进入相机此时高光最强。这个模型天然考虑到了视角变化的影响计算又简单所以不管是固定管线时代还是现在的移动端渲染它都是最稳妥的选择。我最终确定的三层光照结构是这样的环境光Ambient一个常量值模拟场景中无处不在的间接光防止暗部完全死黑。漫反射Diffuse基于 Lambert 定律用表面法线和光源方向的点积控制亮度。镜面反射Specular基于 Blinn-Phong 半程向量用反光度指数控制高光大小和锐利度。这个结构最大的好处是每一层都是独立叠加的不可能出现某一层算出负数拖累整体的情况。叠加时也不需要做复杂的混合直接在计算中相加得到最终颜色。2. 数学建模把“光打在物体上”翻译成代码2.1 漫反射项光线越是正对表面能量越集中漫反射项不需要考虑视角方向因为它描述的是表面把光线均匀散射到各个方向的现象。你从哪个角度看漫反射亮度都是一样的这正好是磨砂表面的视觉特征。公式很简单但每一步都有物理直觉支撑float diff max(dot(normal, lightDir), 0.0); vec3 diffuse diff * lightColor * baseColor;dot(normal, lightDir)其实是两个单位向量的余弦值。余弦的取值区间在 -1 到 1 之间。等于 1 时光线垂直入射能量密度最大等于 0 时光线掠射表面几乎不贡献亮度负数说明光源在背面直接用max砍掉防止出现“背光面反而更亮”的反常现象。我在实现过程中踩过一个细节这个点积必须用插值后的逐像素法线计算而不是顶点法线。一开始为了省性能在顶点着色器里算光照结果球体表面的高光呈现明显的多边形轮廓看上去像切面而不是曲面。后来改成在片元着色器里逐像素计算球体立刻光滑了。如果你的引擎还没有法线插值建议优先补上否则光照效果始终差一口气。2.2 镜面反射项Blinn-Phong 的半程向量不变量镜面反射描述的是光滑表面的行为一束光打过来会沿着反射方向集中弹射出去。只有当视线恰好接近反射方向时你才能看到高光。Phong 的计算方式是实现反射方向再和视线做点积而 Blinn-Phong 换了个更聪明的思路。vec3 viewDir normalize(cameraPos - worldPos); vec3 lightDir normalize(lightPos - worldPos); vec3 halfDir normalize(lightDir viewDir); float spec pow(max(dot(normal, halfDir), 0.0), shininess); vec3 specular spec * lightColor * specularStrength;这个半程向量是 Blinn-Phong 最精妙的部分。当视线方向和光线方向确定后半程向量就是“能让光线直接反射进入视线的那个法线方向”。如果表面真实法线越接近这个理想方向高光越强。pow把这种接近程度放大shininess控制放大的倍数。shininess的取值是视觉调优的重灾区。我测出来的经验值如下1.0到8.0高光范围巨大像大面积油光不适合常规材质。16.0到32.0适合塑料、搪瓷、刚上蜡的木质表面。64.0到128.0适合金属、湿润的石头、光面皮革。256.0以上接近镜面只在高光中心留下一个很小很锐利的亮点。由于这个值是纯经验参数和数据没有关系我建议在调试面板上做成滑动条实时调整实时看效果比自己盲试参数高效得多。2.3 环境光与最终合成公式环境光在简单模型里可以直接用常量代替但有个改进技巧非常实用环境光应该受到漫反射颜色的调制。否则会出现一个红色物体在暗处的颜色还是纯白偏暗看起来像没有受到底色染色。vec3 ambient ambientStrength * ambientColor * baseColor;这样环境光本身携带颜色信息和强度信息同时乘上材质底色背光面会呈现出“这个物体固有颜色在暗处”的感觉而不是灰色。最终合成就是简单相加vec3 finalColor ambient diffuse specular;要提醒的是这个加法是线性空间下的加法。如果你没有做 Gamma 校正那么显示器上看到的颜色是物理计算结果的 sRGB 编码版本高光会显得过曝暗部会发灰。关于 Gamma 的处理我在后面踩坑部分展开。3. Shader 落地与引擎集成3.1 GLSL 顶点着色器把法线变换到世界空间先看一下我的顶点着色器。这里有一个非常关键的细节普通顶点坐标可以直接用模型矩阵变换到世界空间但法线不行。#version 330 core layout(location 0) in vec3 aPos; layout(location 1) in vec3 aNormal; layout(location 2) in vec2 aTexCoord; uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProjection; uniform mat3 uNormalMatrix; out vec3 vWorldPos; out vec3 vNormal; out vec2 vTexCoord; void main() { vec4 worldPos uModel * vec4(aPos, 1.0); vWorldPos worldPos.xyz; vNormal uNormalMatrix * aNormal; vTexCoord aTexCoord; gl_Position uProjection * uView * worldPos; }法线的特性是它是一个方向向量而不是位置向量没有平移的概念。如果模型矩阵里带了非均匀缩放直接用法线乘以模型矩阵会导致法线方向畸变光照结果完全错误。正确做法是用法线的变换矩阵即模型矩阵 3x3 部分的逆的转置这也是 GPU 编程中经常说的“法线矩阵”。我提供一个快速生成法线矩阵的算式假设模型矩阵的 3x3 部分为 M法线矩阵公式就是transpose(inverse(M))。如果 M 只包含旋转和均匀缩放其实可以直接用 M 的 3x3 部分但为了应对未来的非均匀缩放需求我最终还是走了完整的逆转置流程。每帧只算一次开销完全可以接受。3.2 片元着色器逐像素光照计算片元着色器是光照模型的主要工作区。我把光源参数和材质参数都用 Uniform 传进来保持 shading code 的通用性。#version 330 core in vec3 vWorldPos; in vec3 vNormal; in vec2 vTexCoord; out vec4 FragColor; uniform vec3 uCameraPos; uniform sampler2D uAlbedoMap; // 光源参数 uniform vec3 uLightPos; uniform vec3 uLightColor; uniform float uLightIntensity; // 材质参数 uniform vec3 uBaseColor; uniform float uShininess; uniform vec3 uSpecularColor; uniform float uSpecularStrength; void main() { vec3 baseColor texture(uAlbedoMap, vTexCoord).rgb * uBaseColor; vec3 normal normalize(vNormal); vec3 lightDir normalize(uLightPos - vWorldPos); vec3 viewDir normalize(uCameraPos - vWorldPos); vec3 halfDir normalize(lightDir viewDir); // 环境光 float ambientFactor 0.1; vec3 ambient ambientFactor * baseColor; // 漫反射 float diff max(dot(normal, lightDir), 0.0); vec3 diffuse diff * uLightColor * baseColor; // Blinn-Phong 高光 float spec pow(max(dot(normal, halfDir), 0.0), uShininess); vec3 specular uSpecularStrength * spec * uSpecularColor * uLightColor; vec3 color ambient diffuse specular; color * uLightIntensity; FragColor vec4(color, 1.0); }这个 Shader 使用的是一种点光源模型用片段位置到光源位置的方向作为光线方向。如果后续要改成平行光只需要在 Shader 里增加一个uniform vec3 uLightDirection然后直接使用方向而不用做减法代码维护成本也不高。这里我选择把光源参数全部暴露出 Uniform而不是写死在 Shader 里是为了同一个 Shader 可以复用在多个光源下后续做多光源循环的时候可以直接套。3.3 渲染管线接入光源数据结构与 Uniform 传递引擎侧需要做的工作是定义光源管理器。我这里用了一个非常简单的结构体struct Light { glm::vec3 position; glm::vec3 color; float intensity; };渲染物体之前把光源参数绑定到 Shadershader.use(); shader.setVec3(uLightPos, light.position); shader.setVec3(uLightColor, light.color); shader.setFloat(uLightIntensity, light.intensity); shader.setVec3(uCameraPos, camera.position);要特别注意所有位置相关的数据必须统一在同一个坐标空间。我用的统一约定是世界空间顶点着色器里把顶点和法线都变换到世界空间Uniform 里的光源位置和相机位置也是直接传世界坐标。如果你把模型矩阵变换后的顶点传进去但光源位置却用了局部坐标那么光照结果会完全错乱甚至可能出现光源在物体内部、表面全黑的情况。4. 调参实战光照模型的参数意义与效果验证4.1 反光度指数从磨砂到镜面的连续滑杆shininess这个参数非常直观而且调试起来特别有趣。数值越低高光区域越大且分界模糊整个物体表面像是涂了一层油数值越高高光区域越小且分界锐利物体表面更像是抛过光。我做了一个简单的对比实验场景里放一个球体光源在左前方shininess分别设为 4、32、128、512。数值为 4 时高光是整片漫反射中心的一大团亮斑几乎看不出球体表面曲率数值为 512 时高光只剩一个小白点球体转一下角度亮点立刻消失很接近金属球的质感。如果你的目标是常见的游戏材质建议主推 16 到 64 这个区间。4.2 光源颜色与衰减做出晨昏与夜间氛围光源颜色对整体氛围的贡献比很多人想象中大。暖黄色高亮光配合低环境光强度可以营造出黄昏或烛光氛围。冷蓝色光源配合高环境光更像是月光或阴天室外。点光源还要考虑衰减。真实世界中光照强度随距离平方衰减但直接用平方衰减在实时渲染里会出现一个问题近距离时亮度爆表远距离时又迅速收敛到接近 0。很多引擎的做法是增加一个平滑衰减函数。我目前用的是这个式子float distance length(uLightPos - vWorldPos); float attenuation 1.0 / (1.0 0.09 * distance 0.032 * distance * distance);这里把线性项和二次项都加进去了。线性项的优势是远距离衰减不那么剧烈视觉过度更自然二次项则保证了近距离的能量集中。为什么要加那个常数的 1为了在distance0时衰减为 1避免除零和无限大。4.3 常用调试手段可视化法线与光照分量调试光照模型的时候一个非常有效的手段是逐分量可视化。不要直接输出最终结果而是分别输出环境光、漫反射、高光引起的那部分颜色你会发现很多诡异效果其实来源于某一层的异常。比如输出FragColor vec4(normal, 1.0)法线可视化可以很清楚地看到物体表面的法线方向分布右侧红色、上方绿色、朝向你的蓝色。如果颜色分布出现断裂或者突变说明顶点法线有问题或者索引数据有错位。再比如输出FragColor vec4(diffuse, 1.0)可以验证漫反射是否按照光照方向正确变化。剔除某一层的调试方式让我少走了很多弯路。5. 踩坑实录光照开发中的典型问题与排查5.1 法线没归一化断断续续的高光这个坑出现得极其隐蔽。顶点着色器里插值出来的法线在经过三角形重心插值之后长度不再是精确的 1在某些像素上可能只有 0.7 或者 1.2。如果直接把这种法线拿去做点积高光强度会起伏不定表现为物体表面高光闪烁或者出现不规则的带状亮纹。我的解决方法是所有自定义 Shader 中都强调使用normalize()处理法线、视线方向和光线方向。虽然理论上单位向量点积再 pow 的数学等式严格成立但浮点误差和插值误差会导致边界处出现黑线或亮点在低精度移动设备上特别明显。5.2 坐标空间不一致场景明明有光却全黑有一次我把光源位置硬编码在相机空间但物体顶点已经通过模型矩阵变换到了世界空间结果物体表面一片漆黑但光源移动时颜色变化却非常诡异。排查过程花了一个多小时最后才发现空间不统一。这里提醒一点在你熟悉坐标空间变换之前统一用世界空间是最稳的做法。简单来说顶点坐标经模型矩阵变换到世界空间法线经法线矩阵变换到世界空间光源位置和相机位置直接传入世界坐标Shader 内部所有计算都发生在世界空间。这样模型移动、旋转、缩放光照效果都会自动跟着物体一起变化不需要额外处理。5.3 Gamma 校正缺失暗部发灰、高光刺眼没有做 Gamma 校正时我的场景出现了两个非常明显的视觉问题。一是暗部发灰本来应该是很暗的表面看起来像被一层灰雾笼罩二是高光过曝高光区域白成一片完全失去了颜色层次感。解释一下原因显示器对输入颜色的响应是近似 sRGB 曲线物理上接近线性关系的光照计算结果直接输出经过显示器的非线性映射后中间调和暗部会被整体提亮高光则会被压缩到刺眼的白色。解决办法是在片元着色器输出之前加一次反编码vec3 gammaCorrected pow(color, vec3(1.0 / 2.2)); FragColor vec4(gammaCorrected, 1.0);如果引擎里已经加了全局的后处理管线也可以把这一步统一放到后处理阶段这里为了尽量少依赖其他模块直接在 Shader 尾部做。这个改动做完之后整个场景的颜色层次感一下子正常了暗部不再发灰高光也保留了颜色信息。结尾这个模块往后还能怎么扩展在我实际写完这个光照模型模块之后感触最深的是Blinn-Phong 虽然不算物理正确但它是理解 PBR 的最好跳板。半程向量这个思想在 GGX 微表面模型里依然有类似位置环境光加上漫反射加上高光的分解结构也延续到了 PBR 流程中。个人建议你接下来可以试着给这个光照模型添加方向光和聚光灯的支持。方向光只是把lightDir换成一个固定的 Uniform 方向聚光灯则需要额外根据片元和光源中心的夹角计算一个锥形内的强度衰减。这两种光源加上现有的点光源基本可以覆盖一个中小场景里 90% 的光照需求。再往下走就是做多光源循环、阴影映射甚至是基于图像的光照环境贴图采样这条路径的每一步都会让引擎的渲染效果上一个台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑