1. 从“能跑就行”到“看着像那么回事”:光照与材质到底在解决什么问题
如果你已经跟着前面几个阶段把引擎的渲染主循环、网格加载、相机变换这些基础模块搭起来了,那你大概率已经经历过那个阶段——模型能显示出来了,三角形没画错,深度测试也开了,但整个画面看起来就像塑料玩具泡在福尔马林里,平、灰、假。这不是你的几何有问题,也不是矩阵算错了,问题出在光照和材质这两个环节上。
光照和材质是游戏引擎渲染管线里最“出效果”的部分,也是最容易让人陷入参数泥潭的部分。简单说,光照决定了“光怎么打到物体上”,材质决定了“物体表面怎么响应这束光”。这两件事必须放在一起讲,因为它们本质上是同一个计算的两端:光照模型给出入射光的分布,材质模型给出表面反射的特性,两者相乘才得到最终像素颜色。你单独调光照或者单独调材质,永远调不出对的结果。
这个内容适合谁?如果你已经能用自己写的引擎画出带纹理的模型,但画面始终缺乏立体感和真实感,那这篇就是写给你的。如果你还在纠结顶点缓冲怎么绑定,建议先把渲染管线跑通再回来。我会从最基础的 Lambert 漫反射讲起,一路推到 Blinn-Phong、法线贴图、PBR 金属度粗糙度工作流,中间穿插我自己踩过的坑和参数选择的逻辑。所有代码片段都是伪代码风格,重点在思路和公式,你用什么图形 API 都能套进去。
2. 光照模型选型:为什么我从 Lambert 一路换到了 Cook-Torrance
2.1 先搞清楚光照计算的基本骨架
任何光照模型,不管多复杂,骨架都是一样的:对每个着色点,遍历场景中所有光源,计算每个光源对该点的贡献,累加起来。单个光源的贡献可以拆成三部分——漫反射、镜面反射、环境光。漫反射是光进入表面后经过多次散射再出来的部分,跟视角无关;镜面反射是光在表面直接弹开的部分,跟视角强相关;环境光是用来近似全局光照的兜底项,防止背光面死黑。
这个骨架用伪代码写出来大概长这样:
vec3 shade(Surface s, Light l, vec3 viewDir) { vec3 L = normalize(l.position - s.position); vec3 N = s.normal; vec3 V = viewDir; vec3 H = normalize(L + V); float NdotL = max(dot(N, L), 0.0); float NdotV = max(dot(N, V), 0.0); vec3 diffuse = s.albedo / PI * NdotL; vec3 specular = computeSpecular(N, V, L, H, s); return (diffuse + specular) * l.color * l.intensity * NdotL; }注意这里有个容易搞错的地方:NdotL到底乘几次。在基于物理的公式里,漫反射项本身已经包含了NdotL(因为辐照度定义里就有),但很多教程的简化写法会在最后统一乘一次。我建议你在引擎里统一约定:光照公式内部不乘 NdotL,在累加光源贡献时统一乘。这样后面换模型的时候不容易乱。我自己就因为这个问题调试了一整天,明明公式抄对了但画面就是偏暗,最后发现是 NdotL 乘了两遍。
2.2 Lambert 漫反射:够用,但只够用一小会儿
Lambert 模型假设表面是理想漫反射体,光线进入后均匀地向所有方向散射。公式简单到不能再简单:diffuse = albedo * max(dot(N, L), 0)。它的优点是计算量极小,而且在纯漫反射场景(比如粗糙的石膏、布料)里表现还行。
但问题很快就来了。你给一个球加上 Lambert 光照,会发现明暗交界线非常生硬,背光面直接死黑,整个球看起来像被刀切成了两半。这是因为 Lambert 没有考虑环境光的多次反弹,也没有任何镜面高光。对于金属或者光滑表面,Lambert 完全无能为力。
我当时的做法是加了一个常数环境光项ambient = albedo * 0.1,画面确实不那么黑了,但立体感依然很差。这个阶段让我明白一件事:光照模型的选择不是“越复杂越好”,而是“你的场景需要什么”。如果你的游戏是低多边形风格化美术,Lambert 加一点环境光可能就够了;但如果你想要写实一点的质感,必须往上走。
2.3 Blinn-Phong:第一个“看起来像那么回事”的模型
Blinn-Phong 在 Lambert 的基础上加了镜面反射项,用半程向量H = normalize(L + V)来近似反射方向。镜面项写成specular = pow(max(dot(N, H), 0), shininess) * specularColor。这里的shininess控制高光的锐利程度,值越大高光越集中。
这个模型的好处是计算量可控,而且高光效果立竿见影。你给球加上 Blinn-Phong,立刻就能看到一个小亮点在表面滑动,立体感瞬间出来了。但它的参数没有物理意义——shininess取 32 和取 64 到底对应什么粗糙度?没人说得清。而且 Blinn-Phong 不满足能量守恒,高光再亮也不会让漫反射变暗,导致物体看起来像在发光。
我在这个阶段停留了很久,因为调参数确实能出效果。但当我尝试把同一个材质放到不同光照环境下时,问题暴露了:在强光下高光过曝,在弱光下高光消失,材质看起来完全不像同一种东西。这就是非物理模型的根本缺陷——材质参数和光照强度耦合在一起,换个环境就得重新调。
2.4 Cook-Torrance 与 PBR:参数终于有了物理含义
基于物理的渲染(PBR)核心思想是:材质参数描述的是表面本身的物理属性(粗糙度、金属度、反射率),光照计算遵循能量守恒,环境变了材质不变。Cook-Torrance 是常用的镜面反射 BRDF,由三项组成:法线分布函数 D、几何遮蔽函数 G、菲涅尔项 F。
vec3 cookTorranceSpec(vec3 N, vec3 V, vec3 L, vec3 H, float roughness, vec3 F0) { float NDF = distributionGGX(N, H, roughness); float G = geometrySmith(N, V, L, roughness); vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; return (NDF * G * F) / denominator; }这三项各自解决一个问题。D 项(GGX 分布)描述微观法线的分布——粗糙度越低,法线越集中,高光越锐利。G 项描述微观表面的自遮挡——掠射角下部分微面被挡住,避免高光在边缘过亮。F 项描述菲涅尔效应——视线越接近掠射角,反射越强,这就是为什么水面在远处看起来像镜子。
金属度工作流的核心逻辑是:金属没有漫反射,所有颜色来自镜面反射的 F0;非金属的 F0 固定为 0.04 左右,漫反射用 albedo。这个约定让美术只需要调两个参数(金属度、粗糙度)就能覆盖绝大多数材质,而且换环境不用重调。
我切换到 PBR 之后最大的感受是:调材质从“猜参数”变成了“描述物体”。以前调一个铁皮材质要反复试 shininess 和 specular color,现在只需要把金属度拉到 1,粗糙度调到 0.3,出来的东西就是铁皮。这个心智模型的转变比公式本身重要得多。
3. 材质系统的核心细节:从纯色到法线贴图再到 PBR 贴图
3.1 材质数据怎么组织:别把参数散落在各处
最开始我把材质参数直接写在着色器的 uniform 里,每个物体手动设一遍。物体一多就崩了——改一个参数要翻半天代码,而且没法复用。后来我抽了一个 Material 结构体,把 albedo、metallic、roughness、normalScale 这些打包在一起,物体引用材质索引,这才算有了材质系统的雏形。
struct Material { Vec3 albedo; float metallic; float roughness; float normalScale; TextureHandle albedoMap; TextureHandle normalMap; TextureHandle metallicRoughnessMap; TextureHandle aoMap; };这里有个设计决策值得说:metallic 和 roughness 要不要打包到一张贴图里。业界常见做法是把 roughness 放 G 通道、metallic 放 B 通道,共用一张贴图。好处是减少纹理采样次数和显存占用,坏处是美术导出时容易搞错通道。我自己的引擎里选择了打包,因为移动端对采样次数很敏感,而且一旦约定好导出规范,出错概率并不高。
注意:如果你选择打包,一定要在材质导入工具里做通道校验。我遇到过美术把 metallic 和 roughness 放反的情况,画面看起来“有点怪但说不上哪里怪”,排查了很久才发现是通道错了。
3.2 法线贴图:让平面“假装”有细节
法线贴图的原理是把高模的细节法线烘焙到一张 RGB 贴图里,着色时用贴图采样值替换几何法线。这里的关键是切线空间——法线贴图存储的是相对于表面切线空间的法线,这样贴图可以复用到不同朝向的表面上。
计算切线空间需要切线 T、副切线 B、法线 N 三个正交基。T 和 B 通常从网格的 UV 导数算出来,或者在建模软件里导出。我一开始偷懒没算切线,直接用世界空间的近似,结果法线贴图在旋转物体上完全错乱。后来老老实实补了切线计算:
// 从三角形顶点数据计算切线和副切线 Vec3 edge1 = p1 - p0; Vec3 edge2 = p2 - p0; Vec2 deltaUV1 = uv1 - uv0; Vec2 deltaUV2 = uv2 - uv0; float f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y); Vec3 tangent = f * (deltaUV1.y * edge2 - deltaUV2.y * edge1);法线贴图采样后要做一次变换:N = normalize(T * n.x + B * n.y + N_geo * n.z),其中 n 是贴图采样值映射到 [-1,1] 后的结果。这里有个细节:B 的方向要用 handedness 修正,否则法线贴图在镜像 UV 的模型上会反向。我踩过这个坑,一个角色的左脸正常右脸凹陷,查了半天以为是模型问题。
3.3 粗糙度贴图与金属度贴图的实操要点
PBR 工作流里,粗糙度和金属度通常来自贴图而不是常数。粗糙度贴图控制表面各处的光滑程度——比如一把磨损的金属枪,边缘磨损处粗糙度高,中间光滑处粗糙度低。金属度贴图则区分哪些区域是金属、哪些是涂层或污渍。
实操中最大的坑是色彩空间。albedo 贴图是 sRGB 空间,采样后要转线性;而 metallic、roughness、normal、AO 这些是线性数据,绝对不能做 sRGB 转换。我见过有人把所有贴图统一按 sRGB 处理,结果粗糙度整体偏暗,画面像蒙了一层油。正确的做法是在纹理加载时根据用途标记色彩空间,采样时自动处理。
另一个经验是:粗糙度不要直接采样就用,先 clamp 到 [0.04, 1.0]。粗糙度太低会导致 GGX 分布函数在分母上趋近于零,产生刺眼的白点(fireflies)。我一开始没做 clamp,金属球上全是噪点,还以为是采样数不够,后来发现是粗糙度贴图里有纯黑像素。
4. 完整实操流程:从零搭一个能跑的光照材质管线
4.1 第一步:把光照计算拆成可替换的模块
我不建议你把光照公式硬编码在片元着色器里。正确的做法是定义一个光照计算的接口,把不同模型做成可替换的实现。这样你可以在同一个场景里对比 Lambert 和 PBR 的效果,也方便后续加新的光照类型。
我的做法是在着色器里用宏或者分支来切换光照模型,CPU 端通过材质参数控制。伪代码结构如下:
vec3 computeLighting(Surface s, Light l, vec3 V) { #if LIGHTING_MODEL == LAMBERT return lambert(s, l, V); #elif LIGHTING_MODEL == BLINN_PHONG return blinnPhong(s, l, V); #elif LIGHTING_MODEL == PBR return cookTorrance(s, l, V); #endif }这个结构看起来简单,但它强迫你把“表面属性”和“光照计算”解耦。Surface 结构体里只放位置、法线、albedo、metallic、roughness 这些跟光照模型无关的数据,Light 结构体里只放方向、颜色、强度。换模型的时候只改 computeLighting 的实现,其他代码不动。
4.2 第二步:实现直接光照并验证能量守恒
先只做直接光照(一个方向光),不加任何环境光。用一个中性灰的球体测试,检查三件事:亮面是否过曝、暗面是否死黑、高光是否合理。如果暗面完全黑,说明缺少环境光或间接光;如果亮面一片白,说明光照强度太高或者没有做色调映射。
色调映射这一步很多人会跳过,但它对 PBR 至关重要。PBR 计算出来的颜色是线性 HDR 值,直接输出到 sRGB 显示器会过曝。我用的 ACES 近似曲线:
vec3 acesToneMap(vec3 x) { float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14; return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); }色调映射之后还要做 gamma 校正(线性转 sRGB),否则画面整体偏暗。这两个步骤的顺序不能反:先色调映射再 gamma,反过来会得到灰蒙蒙的画面。
4.3 第三步:加入法线贴图并处理切线空间
在直接光照跑通之后,给材质加上法线贴图。测试方法:用一个平面加法线贴图,旋转光源,观察高光是否跟着法线贴图的凹凸变化。如果高光纹丝不动,说明法线贴图没生效;如果高光方向完全错乱,说明切线空间算错了。
我建议在这个阶段做一个调试视图:把法线直接输出成颜色(color = N * 0.5 + 0.5)。正常的法线贴图应该呈现偏蓝紫色的画面(因为 Z 分量占主导),如果看到大片红色或绿色,说明切线或副切线方向有问题。
4.4 第四步:接入 PBR 贴图并做材质校验
把 albedo、metallic、roughness、normal、AO 五张贴图全部接上,用一个标准材质球(比如金属球和非金属球各一个)做校验。金属球的 albedo 应该接近黑色(因为金属没有漫反射),高光颜色来自 F0;非金属球的 F0 固定 0.04,漫反射颜色来自 albedo。
这里有个实用的校验技巧:把 F0 直接输出成颜色。金属的 F0 应该等于 albedo,非金属的 F0 应该接近 0.04 的灰色。如果金属球的 F0 输出是灰色而不是彩色,说明金属度没生效或者 albedo 没正确传入。
4.5 第五步:环境光照与 IBL 的简化实现
直接光照只能照亮朝向光源的面,背光面需要环境光照。最简单的做法是用一张环境贴图做漫反射卷积,但完整 IBL 实现比较复杂。我的建议是分阶段来:先用一个常数环境光加一个简单的半球环境光(天空色和地面色插值),等整个管线稳定了再上 IBL。
半球环境光的实现很简单:ambient = mix(groundColor, skyColor, N.y * 0.5 + 0.5)。这个近似在户外场景里效果不错,而且计算量几乎为零。我用这个方案撑了很久,直到场景里出现金属材质才不得不换 IBL,因为金属的镜面反射需要环境贴图才有东西可反射。
5. 常见问题与排查实录:那些让我熬夜的坑
5.1 画面整体偏暗或偏亮
这是最高频的问题,原因通常有三个:色彩空间没处理对、色调映射缺失、光照强度单位不统一。排查顺序建议从色彩空间开始——检查 albedo 贴图是否做了 sRGB 转线性,检查输出是否做了线性转 sRGB。如果这两步都对,再看色调映射是否启用。最后检查光照强度:方向光的强度在 PBR 里通常用 lux 或者无单位值,不同教程的约定不一样,统一成一套就行。
我自己的教训是:不要在调试阶段同时改多个变量。有一次我同时调了光照强度和曝光,结果画面变亮了但不知道是哪个起的作用,来回试了半小时。后来养成习惯,每次只改一个参数,改完截图对比。
5.2 高光出现噪点或白斑
GGX 分布函数在粗糙度接近零时会产生数值爆炸,表现为高光区域出现刺眼的白点。解决方法是在粗糙度上做 clamp,最小值取 0.04 到 0.08 之间。另外检查法线贴图的强度,法线扰动过大会导致半程向量计算异常,也会产生噪点。
还有一个隐蔽的原因:法线没有归一化。插值后的法线长度不为 1,点积结果超出 [-1,1] 范围,pow 函数就会产生异常值。养成习惯,所有参与光照计算的法线都做一次 normalize。
5.3 金属材质看起来像塑料
金属度设为 1 但画面还是塑料感,通常是因为 albedo 贴图用错了。金属的 albedo 应该表示 F0(反射率),而不是漫反射颜色。如果美术给金属球画了一张亮色的 albedo,金属度拉满后 F0 变成亮色,看起来就像彩色塑料。正确的做法是金属的 albedo 用暗色或者直接用金属的反射率参考值(金、银、铜各有固定的 F0)。
另一个原因是缺少环境反射。金属的视觉特征很大程度来自环境反射,只有直接光照的话,金属球看起来就是一个带高光的暗球。这也是为什么我建议尽早接入 IBL 或者至少加一个环境立方体贴图。
5.4 法线贴图导致光照方向反了
这个问题我在镜像 UV 的模型上遇到过。原因是副切线方向没有根据 UV 的 handedness 修正。检测方法:在着色器里输出dot(cross(N, T), B),正常应该是 +1 或 -1 一致,如果不同三角形符号不同,说明 handedness 没处理。
修正方法是在计算切线时记录 handedness(UV 行列式的符号),在着色器里用它乘副切线。这个细节在大多数教程里被一笔带过,但实际项目中几乎必踩。
5.5 材质切换时画面闪烁
如果切换材质时出现一帧闪烁,大概率是纹理绑定和 uniform 更新的顺序问题。我的经验是把材质的所有状态打包成一个绑定操作,先绑定所有纹理,再统一更新 uniform,最后提交绘制。不要在绘制循环中间穿插状态更新,否则容易出现状态不一致。
下面这张表是我整理的高频问题速查:
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 整体偏暗 | 缺少 gamma 校正 | 检查输出是否做了线性转 sRGB |
| 整体偏亮 | 缺少色调映射 | 确认 HDR 值经过 ACES 或 Reinhard |
| 高光白斑 | 粗糙度太低 | clamp 粗糙度到 0.04 以上 |
| 金属像塑料 | albedo 用了漫反射色 | 金属 albedo 应为 F0 反射率 |
| 法线贴图无效 | 切线空间未计算 | 输出法线调试视图检查 |
| 背光面死黑 | 缺少环境光 | 加半球环境光或 IBL |
| 切换材质闪烁 | 状态更新顺序错 | 纹理绑定与 uniform 更新分离 |
6. 我在实际项目里沉淀下来的几条经验
光照和材质这块,公式可以抄,但参数和流程必须自己趟。我最大的体会是:先把一个球调对,再谈场景。很多人一上来就往场景里堆模型,结果每个物体都灰扑扑的,根本不知道问题出在哪个环节。用一个中性灰球、一个金属球、一个粗糙非金属球做基准测试,这三个球调对了,场景基本不会出大问题。
另一个经验是关于调试视图的。我在引擎里常驻了几个调试模式:法线视图、albedo 视图、粗糙度视图、金属度视图、F0 视图。切换材质或者排查光照问题时,先看这些视图确认数据正确,再去看着色结果。这个习惯帮我省了大量时间,因为大部分“光照不对”的问题,根源其实是材质数据传错了。
最后说一个参数上的小技巧:方向光的强度从 1.0 开始调,环境光从 0.1 开始调。这两个起点在大多数色调映射曲线下都能得到可用的初始画面,然后再根据场景微调。不要一上来就设 10 或者 0.01,那样你会在错误的区间里浪费很多时间。材质方面,非金属的金属度保持 0,粗糙度从 0.5 开始试;金属的金属度设 1,粗糙度从 0.3 开始试。这几个起点覆盖了八成常见材质,剩下的靠贴图去补细节。