OpenGL / OpenCL 编程指南 / 第 7 章:光照与阴影
第 7 章:光照与阴影
光照是让 3D 场景"看起来真实"的关键。本章从经典的冯氏(Phong)光照模型开始,逐步讲解法线贴图和阴影映射。
7.1 光照模型概述
7.1.1 冯氏光照模型
冯氏(Phong)光照模型将光照分为三个分量:
最终颜色 = 环境光 (Ambient) + 漫反射 (Diffuse) + 高光 (Specular)
| 分量 | 物理含义 | 视觉效果 |
|---|---|---|
| Ambient | 全局均匀光照,模拟间接光 | 阴影区域不会完全黑暗 |
| Diffuse | 光线照射到表面后向各方向均匀散射 | 物体的基本明暗 |
| Specular | 光线在光滑表面上的镜面反射 | 高光亮点 |
光源 (Light)
╲ │ ╱
╲ │ ╱ 入射光
╲│╱
─────●───── 表面
│╲
│ ╲ 反射光 → 观察者
│
法线 (Normal)
7.2 冯氏光照实现
7.2.1 光照计算公式
// 环境光
vec3 ambient = ambientStrength * lightColor;
// 漫反射
vec3 norm = normalize(vNormal);
vec3 lightDir = normalize(lightPos - vFragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
// 高光
vec3 viewDir = normalize(cameraPos - vFragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess);
vec3 specular = specularStrength * spec * lightColor;
// 组合
vec3 result = (ambient + diffuse + specular) * objectColor;
7.2.2 片段着色器完整代码
// phong_fragment.glsl
#version 460 core
in vec3 vFragPos;
in vec3 vNormal;
in vec2 vTexCoord;
out vec4 FragColor;
// 材质属性
struct Material {
vec3 ambient;
vec3 diffuse;
vec3 specular;
float shininess;
};
// 光源属性
struct Light {
vec3 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
uniform Material material;
uniform Light light;
uniform vec3 cameraPos;
void main() {
// 环境光
vec3 ambient = light.ambient * material.ambient;
// 漫反射
vec3 norm = normalize(vNormal);
vec3 lightDir = normalize(light.position - vFragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * (diff * material.diffuse);
// 高光
vec3 viewDir = normalize(cameraPos - vFragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * (spec * material.specular);
vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}
7.3 光源类型
7.3.1 方向光(Directional Light)
模拟太阳光,所有光线方向平行:
// 方向光不需要光源位置,只需要方向
struct DirLight {
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
vec3 lightDir = normalize(-light.direction);
7.3.2 点光源(Point Light)
从一个点向所有方向发射光线,强度随距离衰减:
struct PointLight {
vec3 position;
float constant; // 衰减常数项
float linear; // 衰减一次项
float quadratic; // 衰减二次项
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
// 衰减计算
float distance = length(light.position - vFragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * distance * distance);
衰减参数参考:
| 距离 | constant | linear | quadratic |
|---|---|---|---|
| 7 | 1.0 | 0.7 | 1.8 |
| 13 | 1.0 | 0.35 | 0.44 |
| 20 | 1.0 | 0.22 | 0.20 |
| 32 | 1.0 | 0.14 | 0.07 |
| 50 | 1.0 | 0.09 | 0.032 |
| 100 | 1.0 | 0.045 | 0.0075 |
7.3.3 聚光灯(Spotlight)
类似手电筒,光线在锥形范围内:
struct SpotLight {
vec3 position;
vec3 direction;
float cutOff; // 内锥角余弦值
float outerCutOff; // 外锥角余弦值
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
// 聚光灯计算
vec3 lightDir = normalize(light.position - vFragPos);
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
// intensity 用于柔和边缘过渡
diffuse *= intensity;
specular *= intensity;
聚光灯锥体:
╲ outerCutOff ╱
╲ cutOff ╱
╲ angle ╱
╲ ╱
╲ ╱
╲ ╱
╲ ╱
╲ ╱
● ← 光源位置
7.4 Blinn-Phong 改进
Blinn-Phong 使用半程向量(Halfway Vector)替代反射向量,计算更快且在大角度时效果更好:
// 标准 Phong:使用反射向量
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess);
// Blinn-Phong:使用半程向量
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(norm, halfwayDir), 0.0), shininess);
| 特性 | Phong | Blinn-Phong |
|---|---|---|
| 计算量 | 较高 | 较低 |
| 高光形状 | 较小 | 稍大更柔和 |
| 极端角度 | 可能出现断裂 | 更平滑 |
| 使用频率 | 教学 | 实际项目 |
7.5 法线贴图(Normal Mapping)
7.5.1 问题与方案
低多边形模型表面细节不足。法线贴图通过在纹理中存储法线方向,在不增加顶点数的情况下模拟表面凹凸细节。
原始法线(平坦表面): 法线贴图后的法线:
↑ ↑ ↑ ↑ ╱ ↑ ╲ ↑
────┼──┼──┼──┼─ ─────╱──┼──╲──┼─
│ │ │ │ ╱ │ ╲ │
7.5.2 切线空间
法线贴图中的法线存储在切线空间(Tangent Space)中:
切线空间坐标系:
T (Tangent) → 沿纹理 U 方向
B (Bitangent) ↓ 沿纹理 V 方向
N (Normal) ↑ 垂直于表面
7.5.3 实现代码
// 为每个顶点计算切线和副切线
// 在加载模型时计算(假设三角形 ABC)
glm::vec3 edge1 = posB - posA;
glm::vec3 edge2 = posC - posA;
glm::vec2 deltaUV1 = uvB - uvA;
glm::vec2 deltaUV2 = uvC - uvA;
float f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
glm::vec3 tangent;
tangent.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
// 法线贴图片段着色器
#version 460 core
in vec3 vFragPos;
in vec2 vTexCoord;
in mat3 TBN; // 切线空间→世界空间矩阵
out vec4 FragColor;
uniform sampler2D diffuseMap;
uniform sampler2D normalMap;
uniform vec3 lightPos;
uniform vec3 cameraPos;
void main() {
// 从法线贴图采样并转换到 [-1, 1]
vec3 normal = texture(normalMap, vTexCoord).rgb;
normal = normalize(normal * 2.0 - 1.0);
normal = normalize(TBN * normal); // 转换到世界空间
// Phong 光照计算(使用法线贴图的法线)
vec3 color = texture(diffuseMap, vTexCoord).rgb;
vec3 lightDir = normalize(lightPos - vFragPos);
float diff = max(dot(normal, lightDir), 0.0);
vec3 viewDir = normalize(cameraPos - vFragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 ambient = 0.1 * color;
vec3 diffuse = diff * color;
vec3 specular = spec * vec3(0.3);
FragColor = vec4(ambient + diffuse + specular, 1.0);
}
7.6 阴影映射(Shadow Mapping)
7.6.1 原理
阴影映射分两步:
- 深度渲染:从光源视角渲染场景,存储每个像素的深度值
- 阴影判断:从相机视角渲染时,将每个片段变换到光源空间,比较其深度与深度贴图中的值
步骤 1: 从光源渲染深度 步骤 2: 从相机渲染场景
┌────────────────────┐ ┌────────────────────┐
│ 光源视角 │ │ 相机视角 │
│ │ │ │
│ ┌──┐ │ │ ┌──┐ │
│ │ │ 深度=3.2 │ │ │ │ 比较深度 │
│ └──┘ │ │ └──┘ 3.2 vs 4.5 │
│ │ │ → 在阴影中 │
└────────────────────┘ └────────────────────┘
7.6.2 深度帧缓冲配置
// 创建深度贴图
unsigned int depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT,
SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
// 创建帧缓冲
unsigned int depthFBO;
glGenFramebuffers(1, &depthFBO);
glBindFramebuffer(GL_FRAMEBUFFER, depthFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE); // 不需要颜色输出
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
7.6.3 渲染深度贴图
// 第一遍:从光源视角渲染深度
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, 1.0f, 20.0f);
glm::mat4 lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0.0, 1.0, 0.0));
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthFBO);
glClear(GL_DEPTH_BUFFER_BIT);
depthShader.use();
depthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
renderScene(depthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
7.6.4 阴影采样与 PCF
// 阴影计算(片段着色器)
float shadowCalculation(vec4 fragPosLightSpace) {
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
projCoords = projCoords * 0.5 + 0.5; // 转换到 [0, 1]
float closestDepth = texture(shadowMap, projCoords.xy).r;
float currentDepth = projCoords.z;
// 偏移解决 Shadow Acne
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
// PCF(百分比渐进过滤):柔和阴影边缘
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for (int x = -1; x <= 1; ++x) {
for (int y = -1; y <= 1; ++y) {
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;
// 超出光源远裁剪面的不产生阴影
if (projCoords.z > 1.0) shadow = 0.0;
return shadow;
}
7.7 多光源实现
// 多光源片段着色器
#define NR_POINT_LIGHTS 4
uniform DirLight dirLight;
uniform PointLight pointLights[NR_POINT_LIGHTS];
uniform SpotLight spotLight;
void main() {
vec3 result = calcDirLight(dirLight, norm, viewDir);
for (int i = 0; i < NR_POINT_LIGHTS; i++) {
result += calcPointLight(pointLights[i], vFragPos, norm, viewDir);
}
result += calcSpotLight(spotLight, vFragPos, norm, viewDir);
FragColor = vec4(result, 1.0);
}
7.8 注意事项
⚠️ Shadow Acne:由于深度精度问题,表面会自相交产生条纹阴影。使用
bias偏移解决。
⚠️ Peter Panning:bias 过大会导致阴影与物体分离。使用
GL_CLAMP_TO_BORDER+ 边框白色缓解。
⚠️ 法线贴图坐标系:法线贴图通常是 [0,1] 范围的 RGB 图像,使用时需转换到 [-1,1]:
normal = tex.rgb * 2.0 - 1.0。
⚠️ 性能:每个阴影投射光源需要额外一遍渲染。限制阴影光源数量(通常 1-2 个)。
7.9 业务场景
| 场景 | 光照方案 |
|---|---|
| 室内场景 | 点光源 + 环境光遮蔽(SSAO) |
| 户外场景 | 方向光(太阳)+ 天空盒环境光 |
| 恐怖游戏 | 手电筒(聚光灯)+ 动态阴影 |
| 建筑可视化 | IBL(基于图像的照明)+ 柔和阴影 |
7.10 扩展阅读
| 资源 | 说明 |
|---|---|
| Learn OpenGL - Lighting | 光照入门 |
| Learn OpenGL - Shadow Mapping | 阴影映射 |
| GPU Gems 2 - Shadow Techniques | 高级阴影技术 |
本章小结
- 冯氏光照 = 环境光 + 漫反射 + 高光
- 方向光模拟太阳,点光源有衰减,聚光灯有锥形范围
- Blinn-Phong 使用半程向量,比标准 Phong 更高效
- 法线贴图通过存储法线方向模拟表面细节,需在切线空间计算
- 阴影映射分两步:深度渲染 + 阴影比较,PCF 产生柔和边缘
- 多光源通过分函数计算后累加
上一章:第 6 章:坐标变换 下一章:第 8 章:高级 OpenGL