强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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);

衰减参数参考

距离constantlinearquadratic
71.00.71.8
131.00.350.44
201.00.220.20
321.00.140.07
501.00.090.032
1001.00.0450.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);
特性PhongBlinn-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. 阴影判断:从相机视角渲染时,将每个片段变换到光源空间,比较其深度与深度贴图中的值
步骤 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