第5章 光照模型(Lighting Models)
一、导读
1.1 本章学习目标
光照是计算机图形学中让三维场景产生真实感的核心技术。没有光照,渲染出的图像只是扁平的几何形状;有了光照,物体才呈现出立体感、材质质感和空间深度。从早期的简单光照模型到现代基于物理的渲染(PBR),光照技术的发展直接推动了图形学从"看起来像"到"物理正确"的飞跃。
本章的核心学习目标包括:
- 理解光与物质交互的物理基础:掌握吸收、反射、折射、散射等光学现象,理解为什么物体呈现不同颜色和质感。
- 掌握经典光照模型:深入理解 Phong 光照模型和 Blinn-Phong 模型的数学推导,包括环境光、漫反射和镜面反射三个分量。
- 理解着色方法:掌握平面着色、Gouraud 着色和 Phong 着色的区别,理解法线插值在平滑着色中的关键作用。
- 掌握多种光源类型:点光源、方向光、聚光灯的物理特性和数学表示,以及衰减函数的设计。
- 了解基于物理的渲染(PBR):理解 BRDF、能量守恒、微表面理论等 PBR 核心概念,掌握 Cook-Torrance 反射模型。
- 实现完整的光照系统:在 OpenGL 中实现多光源、多材质的光照效果,包括法线贴图支持。
1.2 为什么本章如此重要
光照模型是区分"计算机生成的图像"和"照片级真实图像"的关键因素。一个简单的光照模型可以让场景看起来有立体感,而一个精确的物理光照模型可以让渲染结果与真实照片几乎无法区分。
从实践角度看,光照是着色器编程中最核心的应用。几乎所有游戏引擎、渲染引擎和三维软件的核心渲染技术都围绕光照展开。理解光照模型的数学原理,你才能:
- 正确设置场景中的光源和材质
- 调试渲染中的光照问题(如过亮、过暗、不自然的高光)
- 实现高级效果(如阴影、全局光照、次表面散射)
- 理解现代 PBR 工作流中的参数含义
此外,光照模型的发展史也是计算机图形学从"经验公式"走向"物理正确"的缩影。从 Phong 模型的经验近似到 PBR 的物理精确,理解这个演进过程有助于你判断何时使用什么技术。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
为什么 Phong 模型将光照分为环境光、漫反射和镜面反射三个分量?每个分量分别模拟了什么物理现象?
Blinn-Phong 模型与 Phong 模型的镜面反射计算有什么区别?为什么 Blinn-Phong 在实践中更常用?
Gouraud 着色和 Phong 着色的性能和质量各有什么差异?在什么情况下 Gouraud 着色会出现明显的视觉瑕疵?
什么是 BRDF?它如何描述光与表面的交互?能量守恒对 BRDF 有什么约束?
PBR 中的"金属度-粗糙度"工作流是什么意思?金属和非金属在光照响应上有什么本质区别?
为什么在 sRGB 空间中进行光照计算会导致不正确的结果?"伽马正确"的渲染流程是什么?
二、核心概念详解
2.1 光与表面的交互
2.1.1 光的物理本质
光是电磁波,可见光的波长范围约为 380nm(紫)到 780nm(红)。当光到达物体表面时,会发生以下几种交互:
- 吸收(Absorption):光能被表面材料吸收并转化为热能。不同波长的光被吸收的程度不同,这决定了物体的颜色。
- 反射(Reflection):光从表面弹回。分为镜面反射(规则反射)和漫反射(不规则反射)。
- 折射(Refraction):光穿过表面进入物体内部,方向发生偏折。
- 散射(Scattering):光在物体内部多次散射后从其他位置射出(次表面散射)。
光与表面的交互:
入射光
↓
────────────╲──────────── 表面
╲
反射光 ← ╲ → 折射光
╲
╲ ↓ 吸收
╲
次表面散射2.1.2 材质的光学分类
根据光与表面的交互方式,材质大致分为:
- 漫反射材质(Diffuse):光在表面均匀散射到各个方向,如石膏、粉笔。视觉上呈现哑光效果。
- 镜面反射材质(Specular/Mirror):光在表面按反射定律反射,如镜子、抛光金属。
- 光泽材质(Glossy):介于漫反射和镜面反射之间,反射有方向性但不完全规则,如塑料、丝绸。
- 透明材质(Transparent):光穿过物体,如玻璃、水。
- 次表面散射材质(SSS):光进入物体内部散射后从其他位置射出,如皮肤、大理石、牛奶。
2.2 Phong 光照模型
2.2.1 模型概述
Phong 光照模型(1975)是最经典的局部光照模型,它将光照分为三个独立的分量:
最终颜色 = 环境光 + 漫反射 + 镜面反射
I = Iₐ · kₐ + I_d · k_d · max(0, N·L) + I_s · k_s · max(0, R·V)^n其中:
- Iₐ, I_d, I_s:光源的环境光、漫反射、镜面反射强度
- kₐ, k_d, k_s:材质的环境光、漫反射、镜面反射系数
- N:表面法线
- L:光源方向
- V:观察方向
- R:反射方向
- n:镜面反射指数(shininess)
2.2.2 环境光分量
环境光模拟场景中经过多次反弹后均匀照射到所有表面的间接光照。它是一个简化的近似,避免未被直接光照照射的区域完全黑暗。
// 环境光计算
vec3 ambient = ambientStrength * lightColor;
// ambientStrength 通常很小,如 0.12.2.3 漫反射分量(Lambert 余弦定律)
漫反射基于 Lambert 余弦定律:表面接收到的光能与表面法线和光源方向之间夹角的余弦成正比。
漫反射强度 = max(0, N · L)
当 N 和 L 平行(表面正对光源)时,漫反射最强
当 N 和 L 垂直时,漫反射为零
当 N 和 L 夹角大于 90° 时,表面背对光源,漫反射为零// 漫反射计算
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor * materialDiffuse;2.2.4 镜面反射分量
镜面反射模拟表面的高光效果。Phong 模型使用观察方向与反射方向的夹角来计算:
镜面反射强度 = max(0, R · V)^n
R = 2(N · L)N - L (反射方向)
n = 镜面反射指数,越大高光越小越亮// Phong 镜面反射
float specularStrength = 0.5;
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
vec3 specular = specularStrength * spec * lightColor;2.3 Blinn-Phong 模型
2.3.1 改进思路
Blinn-Phong 模型(1977)对 Phong 的镜面反射计算进行了改进。它不再计算反射方向 R,而是引入半角向量 H(halfway vector):
H = normalize(L + V)
镜面反射强度 = max(0, N · H)^n半角向量 H 是光源方向和观察方向的角平分线。当 N 与 H 对齐时,表面正好处于产生最大镜面反射的角度。
// Blinn-Phong 镜面反射
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(norm, halfwayDir), 0.0), 32.0);
vec3 specular = specularStrength * spec * lightColor;2.3.2 为什么 Blinn-Phong 更优
- 计算效率:
normalize(L + V)比reflect(-L, N)计算量更小 - 视觉效果:Blinn-Phong 的高光更自然,尤其是在掠射角度下
- GPU 友好:现代 GPU 的硬件光照计算使用 Blinn-Phong 模型
Phong vs Blinn-Phong 高光对比:
Phong: 高光边缘可能出现不连续(当 R·V 接近 0 时)
Blinn-Phong: 高光更平滑、更圆润,更接近真实物理行为2.4 着色方法
2.4.1 平面着色(Flat Shading)
每个图元(三角形)使用一个颜色。法线在图元级别计算(通过叉积),整个图元使用相同的法线进行光照计算。
// 平面着色法线计算
glm::vec3 flatNormal(const glm::vec3& v0,
const glm::vec3& v1,
const glm::vec3& v2) {
glm::vec3 edge1 = v1 - v0;
glm::vec3 edge2 = v2 - v0;
return glm::normalize(glm::cross(edge1, edge2));
}优点:计算量最小
缺点:低多边形物体呈现明显的"面片感",不光滑
2.4.2 Gouraud 着色
在顶点处计算光照,然后在图元内部对颜色进行插值。需要为每个顶点提供法线(通常是对相邻面法线求平均)。
// 顶点法线计算(相邻面法线平均)
void computeVertexNormals(std::vector<glm::vec3>& vertices,
std::vector<unsigned int>& indices,
std::vector<glm::vec3>& normals) {
normals.assign(vertices.size(), glm::vec3(0.0f));
for (size_t i = 0; i < indices.size(); i += 3) {
glm::vec3 v0 = vertices[indices[i]];
glm::vec3 v1 = vertices[indices[i+1]];
glm::vec3 v2 = vertices[indices[i+2]];
glm::vec3 faceNormal = glm::cross(v1 - v0, v2 - v0);
normals[indices[i]] += faceNormal;
normals[indices[i+1]] += faceNormal;
normals[indices[i+2]] += faceNormal;
}
for (auto& n : normals) {
n = glm::normalize(n);
}
}// Gouraud 着色:光照在顶点着色器中计算
// 顶点着色器
out vec3 vColor;
void main() {
vec3 norm = normalize(normalMatrix * aNormal);
vec3 lightDir = normalize(lightPos - (model * vec4(aPos, 1.0)).xyz);
float diff = max(dot(norm, lightDir), 0.0);
vColor = (ambient + diff * lightColor) * objectColor;
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
// 片元着色器(直接输出插值后的颜色)
in vec3 vColor;
void main() {
FragColor = vec4(vColor, 1.0);
}优点:比平面着色光滑
缺点:高光可能丢失(如果高光完全在三角形内部而不在顶点处);Mach bands 视觉伪影
2.4.3 Phong 着色
在顶点处传递法线,在片元级别进行插值和光照计算。这是现代渲染中最常用的方法。
// Phong 着色:光照在片元着色器中计算
// 顶点着色器(只传递数据)
out vec3 vFragPos;
out vec3 vNormal;
void main() {
vFragPos = vec3(model * vec4(aPos, 1.0));
vNormal = mat3(transpose(inverse(model))) * aNormal;
gl_Position = projection * view * vec4(vFragPos, 1.0);
}
// 片元着色器(逐片元计算光照)
in vec3 vFragPos;
in vec3 vNormal;
void main() {
vec3 norm = normalize(vNormal);
vec3 lightDir = normalize(lightPos - vFragPos);
vec3 viewDir = normalize(viewPos - vFragPos);
// 环境光
vec3 ambient = 0.1 * lightColor;
// 漫反射
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
// 镜面反射(Blinn-Phong)
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(norm, halfwayDir), 0.0), 32.0);
vec3 specular = 0.5 * spec * lightColor;
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);
}优点:高质量的光滑效果,正确的高光渲染
缺点:计算量比 Gouraud 着色大(但现代 GPU 完全可以承受)
2.5 光源类型
2.5.1 方向光(Directional Light)
模拟无限远的光源(如太阳),所有光线平行,没有衰减。
struct DirLight {
vec3 direction; // 光照方向(指向光源)
vec3 color;
};
vec3 calcDirLight(DirLight light, vec3 normal, vec3 viewDir) {
vec3 lightDir = normalize(-light.direction);
float diff = max(dot(normal, lightDir), 0.0);
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);
vec3 ambient = 0.1 * light.color;
vec3 diffuse = diff * light.color;
vec3 specular = 0.5 * spec * light.color;
return ambient + diffuse + specular;
}2.5.2 点光源(Point Light)
从一个点向所有方向发射光线,有距离衰减。
struct PointLight {
vec3 position;
vec3 color;
float constant; // 常数衰减项
float linear; // 线性衰减项
float quadratic; // 二次衰减项
};
float attenuation(float distance, float constant,
float linear, float quadratic) {
return 1.0 / (constant + linear * distance + quadratic * distance * distance);
}
vec3 calcPointLight(PointLight light, vec3 normal,
vec3 fragPos, vec3 viewDir) {
vec3 lightDir = normalize(light.position - fragPos);
float distance = length(light.position - fragPos);
float atten = attenuation(distance, light.constant,
light.linear, light.quadratic);
float diff = max(dot(normal, lightDir), 0.0);
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);
vec3 ambient = 0.1 * light.color * atten;
vec3 diffuse = diff * light.color * atten;
vec3 specular = 0.5 * spec * light.color * atten;
return ambient + diffuse + specular;
}2.5.3 聚光灯(Spot Light)
从一个点发射锥形范围内的光线,有方向和角度限制。
struct SpotLight {
vec3 position;
vec3 direction; // 聚光灯朝向
float cutOff; // 内锥角余弦值
float outerCutOff; // 外锥角余弦值
vec3 color;
};
vec3 calcSpotLight(SpotLight light, vec3 normal,
vec3 fragPos, vec3 viewDir) {
vec3 lightDir = normalize(light.position - fragPos);
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
float diff = max(dot(normal, lightDir), 0.0);
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);
vec3 ambient = 0.1 * light.color;
vec3 diffuse = diff * light.color;
vec3 specular = 0.5 * spec * light.color;
return (ambient + diffuse + specular) * intensity;
}2.6 基于物理的渲染(PBR)
2.6.1 BRDF 基础
BRDF(Bidirectional Reflectance Distribution Function,双向反射分布函数)描述了光从入射方向 ωᵢ 反射到出射方向 ωₒ 的比例:
f(ωᵢ, ωₒ) = dLₒ(ωₒ) / (Lᵢ(ωᵢ) · cos(θᵢ) · dωᵢ)
BRDF 必须满足两个物理约束:
1. 非负性:f(ωᵢ, ωₒ) ≥ 0
2. 能量守恒:∫∫ f(ωᵢ, ωₒ) · cos(θᵢ) dωᵢ dωₒ ≤ 1BRDF 通常分为两个分量:
- 漫反射 BRDF:通常使用 Lambertian 模型 f_d = k_d / π
- 镜面反射 BRDF:使用微表面模型(如 Cook-Torrance)
2.6.2 Cook-Torrance 镜面反射模型
Cook-Torrance 模型基于微表面理论:表面由无数微小的完美镜面组成,粗糙度决定了微表面的方向分布。
D(h) · F(v, h) · G(l, v, h)
f_spec = ─────────────────────────────
4 · (n·l + 1e-5) · (n·v + 1e-5)
D: 法线分布函数(NDF)- 微表面法线朝向半角向量 h 的比例
F: 菲涅尔方程 - 不同观察角度下的反射率
G: 几何函数 - 微表面之间的遮挡和阴影// PBR 核心函数
// GGX/Trowbridge-Reitz 法线分布函数
float DistributionGGX(vec3 N, vec3 H, float roughness) {
float a = roughness * roughness;
float a2 = a * a;
float NdotH = max(dot(N, H), 0.0);
float NdotH2 = NdotH * NdotH;
float nom = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = 3.14159265 * denom * denom;
return nom / denom;
}
// Schlick 近似的几何函数
float GeometrySchlickGGX(float NdotV, float roughness) {
float r = (roughness + 1.0);
float k = (r * r) / 8.0;
return NdotV / (NdotV * (1.0 - k) + k);
}
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx2 = GeometrySchlickGGX(NdotV, roughness);
float ggx1 = GeometrySchlickGGX(NdotL, roughness);
return ggx1 * ggx2;
}
// Schlick 菲涅尔近似
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) {
return F0 + (max(vec3(1.0 - roughness), F0) - F0) *
pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}2.6.3 金属度-粗糙度工作流
现代 PBR 使用"金属度-粗糙度"工作流描述材质:
- 基础颜色(Albedo):表面的基础颜色
- 金属度(Metallic):0(非金属)到 1(金属)
- 粗糙度(Roughness):0(完全光滑)到 1(完全粗糙)
// 完整的 PBR 片元着色器
#version 450 core
in vec3 vWorldPos;
in vec3 vNormal;
in vec2 vTexCoord;
out vec4 FragColor;
uniform vec3 camPos;
uniform sampler2D albedoMap;
uniform sampler2D metallicMap;
uniform sampler2D roughnessMap;
uniform sampler2D normalMap;
struct Light {
vec3 position;
vec3 color;
float intensity;
};
uniform Light lights[4];
uniform int lightCount;
const float PI = 3.14159265359;
void main() {
vec3 albedo = texture(albedoMap, vTexCoord).rgb;
float metallic = texture(metallicMap, vTexCoord).r;
float roughness = texture(roughnessMap, vTexCoord).r;
vec3 N = getNormalFromMap();
vec3 V = normalize(camPos - vWorldPos);
// F0: 基础反射率
vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);
vec3 Lo = vec3(0.0);
for (int i = 0; i < lightCount; i++) {
vec3 L = normalize(lights[i].position - vWorldPos);
vec3 H = normalize(V + L);
float distance = length(lights[i].position - vWorldPos);
float attenuation = 1.0 / (distance * distance);
vec3 radiance = lights[i].color * lights[i].intensity * attenuation;
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
vec3 numerator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) *
max(dot(N, L), 0.0) + 0.0001;
vec3 specular = numerator / denominator;
vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
kD *= 1.0 - metallic;
float NdotL = max(dot(N, L), 0.0);
Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}
vec3 ambient = vec3(0.03) * albedo;
vec3 color = ambient + Lo;
// HDR 色调映射
color = color / (color + vec3(1.0));
// Gamma 校正
color = pow(color, vec3(1.0 / 2.2));
FragColor = vec4(color, 1.0);
}三、实战案例
3.1 多光源场景
以下代码实现一个支持方向光、点光源和聚光灯的完整光照场景:
// 光照管理器
class LightingSystem {
public:
struct LightData {
// 方向光
glm::vec3 dirLightDir = glm::vec3(-0.2f, -1.0f, -0.3f);
glm::vec3 dirLightColor = glm::vec3(1.0f, 0.98f, 0.95f);
// 点光源
struct PointLight {
glm::vec3 position;
glm::vec3 color;
float constant = 1.0f;
float linear = 0.09f;
float quadratic = 0.032f;
};
std::vector<PointLight> pointLights;
// 聚光灯
struct SpotLightData {
glm::vec3 position;
glm::vec3 direction;
float cutOff = glm::cos(glm::radians(12.5f));
float outerCutOff = glm::cos(glm::radians(17.5f));
glm::vec3 color;
};
std::vector<SpotLightData> spotLights;
};
void uploadToShader(GLuint shader) const {
// 上传方向光
glUniform3fv(glGetUniformLocation(shader, "dirLight.direction"),
1, glm::value_ptr(data.dirLightDir));
glUniform3fv(glGetUniformLocation(shader, "dirLight.color"),
1, glm::value_ptr(data.dirLightColor));
// 上传点光源
glUniform1i(glGetUniformLocation(shader, "pointLightCount"),
data.pointLights.size());
for (size_t i = 0; i < data.pointLights.size(); i++) {
std::string prefix = "pointLights[" + std::to_string(i) + "].";
glUniform3fv(glGetUniformLocation(shader, (prefix + "position").c_str()),
1, glm::value_ptr(data.pointLights[i].position));
glUniform3fv(glGetUniformLocation(shader, (prefix + "color").c_str()),
1, glm::value_ptr(data.pointLights[i].color));
glUniform1f(glGetUniformLocation(shader, (prefix + "constant").c_str()),
data.pointLights[i].constant);
glUniform1f(glGetUniformLocation(shader, (prefix + "linear").c_str()),
data.pointLights[i].linear);
glUniform1f(glGetUniformLocation(shader, (prefix + "quadratic").c_str()),
data.pointLights[i].quadratic);
}
}
LightData data;
};3.2 法线贴图光照
法线贴图通过在片元级别扰动法线来模拟表面细节,无需增加几何复杂度:
// 法线贴图光照
vec3 getNormalFromMap() {
vec3 tangentNormal = texture(normalMap, vTexCoord).xyz * 2.0 - 1.0;
vec3 Q1 = dFdx(vWorldPos);
vec3 Q2 = dFdy(vWorldPos);
vec2 st1 = dFdx(vTexCoord);
vec2 st2 = dFdy(vTexCoord);
vec3 N = normalize(vNormal);
vec3 T = normalize(Q1 * st2.t - Q2 * st1.t);
vec3 B = normalize(cross(N, T));
mat3 TBN = mat3(T, B, N);
return normalize(TBN * tangentNormal);
}四、本章小结
本章系统讲解了光照模型从经典到现代的完整知识体系:
光与表面的交互包括吸收、反射、折射和散射,不同交互方式决定了物体的视觉外观。
Phong 光照模型将光照分为环境光、漫反射和镜面反射三个分量,是理解光照的基础。
Blinn-Phong 模型通过半角向量改进了镜面反射计算,效率更高、效果更自然。
着色方法从平面着色到 Phong 着色,质量递增,现代渲染普遍使用逐片元的 Phong 着色。
光源类型包括方向光、点光源和聚光灯,各有不同的衰减特性和适用场景。
PBR 基于物理光学原理,使用 BRDF、微表面理论和能量守恒,实现了物理正确的光照渲染。金属度-粗糙度工作流已成为行业标准。
五、思考与练习
思考题
Phong 模型的环境光分量是一个" hack",它有什么问题?全局光照技术如何替代它?
为什么金属材质的镜面反射带有颜色(F0 = albedo),而非金属的镜面反射是灰白色的(F0 ≈ 0.04)?
粗糙度如何影响 BRDF 的各个分量(D、F、G)?粗糙度为 0 和 1 时分别是什么效果?
为什么能量守恒对渲染很重要?如果一个材质不遵守能量守恒,视觉上会出现什么问题?
法线贴图的光照计算需要在什么坐标空间中进行?为什么切线空间是最佳选择?
编程练习
多光源场景:实现一个支持 1 个方向光 + 4 个点光源 + 1 个聚光灯的场景,支持光源的开关和参数调整。
材质对比:创建一组球体,分别展示不同的金属度和粗糙度组合(金属度 0/1 × 粗糙度 0/0.5/1),观察光照效果差异。
法线贴图:实现法线贴图支持,对比有无法线贴图时的表面细节表现。