Skip to content

OpenGL 中 Spotlight 的聚光、衰减与柔和边缘效果实现

在 3D 场景中,聚光灯(Spotlight)是一种非常常见且具有强烈视觉效果的光源类型,常用于手电筒、舞台聚光灯以及路灯等场景。

本文将实现一个包含聚光方向截断(Spotlight)、**距离衰减(Attenuation)以及柔和边缘过渡(Soft Edges)**的完整片段着色器,并深入探讨经典 Phong 镜面反射模型 与 现代 Blinn-Phong 模型 的区别。


一、 完整的片段着色器代码 (GLSL)

我们采用现代的 Blinn-Phong 光照模型 作为镜面反射的默认实现(能有效防止 Phong 模型在大角度下的高光截断现象),并在内部展示两者的写法对比。

glsl
# version 330 core
out vec4 FragColor;

// 材质属性结构体
struct Material {
    sampler2D diffuse;   // 漫反射贴图 (RGB 颜色)
    sampler2D specular;  // 镜面高光贴图 (灰度或 RGB)
    float shininess;     // 反射光泽度 (反光度系数)
}; 

// 聚光灯光源属性结构体
struct Light {
    vec3 position;       // 光源在世界/观察空间的位置
    vec3 direction;      // 聚光灯照射的中心轴线方向向量
    float cutOff;        // 内锥角余弦值 (cos(theta_inner)),最亮的核心区域
    float outerCutOff;   // 外锥角余弦值 (cos(theta_outer)),光线完全消失的边界
  
    vec3 ambient;        // 环境光强度系数
    vec3 diffuse;        // 漫反射光强度系数
    vec3 specular;       // 镜面高光强度系数
	
    // 距离衰减常数
    float constant;      // 常数项 (通常为 1.0,防止分母为零)
    float linear;        // 一次线性项
    float quadratic;     // 二次平方项
};

in vec3 FragPos;         // 片段在世界空间的位置
in vec3 Normal;          // 片段的法线向量
in vec2 TexCoords;       // 纹理坐标
  
uniform vec3 viewPos;    // 相机在世界空间的位置
uniform Material material;
uniform Light light;

void main()
{
    // ==========================================
    // 1. 基础方向向量准备
    // ==========================================
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(light.position - FragPos); // 从片段指向光源的向量
    vec3 viewDir = normalize(viewPos - FragPos);         // 从片段指向相机的视线向量

    // ==========================================
    // 2. 基础光照计算 (基于 Blinn-Phong)
    // ==========================================
    // (1) 环境光 (Ambient) - 不受聚光灯照射角度影响
    vec3 ambient = light.ambient * texture(material.diffuse, TexCoords).rgb;
    
    // (2) 漫反射 (Diffuse) - 兰伯特模型
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = light.diffuse * diff * texture(material.diffuse, TexCoords).rgb;  
    
    // (3) 镜面高光 (Specular)
    // ───【Blinn-Phong 实现 (推荐)】───
    vec3 halfwayDir = normalize(lightDir + viewDir); // 半角向量
    float spec = pow(max(dot(norm, halfwayDir), 0.0), material.shininess);
    
    /* ───【经典 Phong 实现 (对比)】───
    vec3 reflectDir = reflect(-lightDir, norm);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
    */
    vec3 specular = light.specular * spec * texture(material.specular, TexCoords).rgb;  
    
    // ==========================================
    // 3. 聚光灯边缘柔和度计算 (Soft Edges)
    // ==========================================
    // 计算当前片段与光轴中心线的夹角余弦值 (theta)
    float theta = dot(lightDir, normalize(-light.direction)); 
    
    // 内外锥角余弦值之差 (epsilon)
    float epsilon = (light.cutOff - light.outerCutOff);
    
    // 线性插值并限幅在 [0.0, 1.0]
    float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
    
    // 仅漫反射和镜面光受聚光灯强度的控制 (环境光不受限制)
    diffuse  *= intensity;
    specular *= intensity;
    
    // ==========================================
    // 4. 距离衰减计算 (Attenuation)
    // ==========================================
    float distance    = length(light.position - FragPos);
    float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * (distance * distance));    
    
    // 衰减影响所有的光照分量(让远处的物体整体变暗)
    ambient  *= attenuation; 
    diffuse   *= attenuation;
    specular *= attenuation;   
        
    // ==========================================
    // 5. 合成输出
    // ==========================================
    vec3 result = ambient + diffuse + specular;
    FragColor = vec4(result, 1.0);
}

二、 核心机制剖析

1. 聚光灯的柔和边缘过渡(Soft Edges)

早期的聚光灯只使用单个切光角(cutOff),导致光圈边缘非常生硬,缺乏真实感。为了实现柔和的边缘,我们引入了双锥角模型

(1) 几何模型示意图

光源 (Position)外锥 (outerCutOff)内锥 (cutOff)照射面高亮区 (Intensity = 1.0)渐变羽化区 (1.0 ~ 0.0)渐变羽化区 (1.0 ~ 0.0)全暗 (0.0)全暗 (0.0)

(2) 为什么使用余弦值?

在 Shader 中,为了避免昂贵的反三角函数(如 acos)计算,我们把所有的角度(、内锥角 、外锥角 )全部转换为其余弦值(Cosine)

  • 的余弦:theta = dot(lightDir, normalize(-light.direction))。由于两个向量均已归一化,点积直接等于夹角余弦值。
  • 内锥角余弦:light.cutOff = cos(phi_in)
  • 外锥角余弦:light.outerCutOff = cos(phi_out)

注意:在角度范围 内,角度越大,其余弦值越小。所以 light.cutOff(小角度)大于 light.outerCutOff(大角度)。

(3) 渐变插值公式

当片段位于内锥和外锥之间时,我们计算其线性过渡强度

  • (片段处于内锥内部):分子大于分母,除法结果 ,被 clamp 截断为 1.0(完全亮)。
  • (片段处于外锥外部):分子 ,除法结果 ,被 clamp 截断为 0.0(完全暗)。
  • 当夹角处于两者之间:强度值在 之间平滑渐变,实现了边缘的羽化过渡。

2. 经典 Phong 与 现代 Blinn-Phong Specular 对比

文章在镜面反射(Specular)的计算中,提供了两种经典的实现方式,它们的区别如下:

Phong 镜面反射表面法线 N入射光 L反射光 R视线 Vα以反射角 R 和视线 V 的夹角 α 计算Blinn-Phong 镜面反射表面法线 N入射光 L视线 V半角 Hθ以法线 N 和半角 H 的夹角 θ 计算

关键区别总结

特性Phong 模型Blinn-Phong 模型
主要变量视角方向与反射光线方向的夹角。表面法线与半角向量(halfwayDir)的夹角。
高光截止问题当视线与反射光的夹角大于 时,点积变负,高光会在过渡处瞬间消失(产生生硬的边界截断)。无论视角如何变化,半角向量与法线夹角总能平滑过渡,完美解决了高光瞬间截止的问题
计算开销需要调用 reflect 计算反射向量,包含较多的矩阵/向量运算。仅需简单向量相加并归一化 normalize(lightDir + viewDir),计算成本更低。
物理学拟真属于早期纯经验模型,对某些材质高光刻画不够真实。更贴近基于物理的微表面分布模型,能量分布更合理。

在实际渲染中,Blinn-Phong 模型已经是现代实时图形学的主流首选


3. 距离衰减(Attenuation)

随着片段与光源距离的增加,光照强度应当衰减。我们使用物理学与工程实践结合的经典二次衰减公式:

  • :片段到光源的欧几里得距离 length(light.position - FragPos)
  • constant):常数项,通常设为 1.0。主要用于确保当距离极其接近零时,分母不小于 1,防止光强数值溢出(除零错误)。
  • linear):一次线性项,控制中距离下的匀速衰减。
  • quadratic):二次项,控制远距离下的快速衰减,模拟真实光线在空间中的散射与吸收。

三、 聚光灯光照合成流程

最终的片段颜色合成逻辑如下:

通过这一套完整的计算流程,不仅可以展现出聚光灯特有的光圈效果,同时让光圈的边缘具备极其自然的毛玻璃/柔和羽化过渡,完美模拟了现实生活中的手电筒及探照灯特性。

基于 VitePress 强力驱动 | 记录技术与生活