需要预先学习:


1. Phong 反射模型

着色与渲染管线 中给出了 Blinn-Phong 反射模型的数学形式,这篇笔记把它的前身——Phong 反射模型(Phong reflection model)——与 Blinn-Phong 放在一起对比,并分别给出对应的 GLSL 实现。

两个模型都是局部光照模型(local illumination model):每个片元独立计算颜色,只依赖光源、表面材质与观察方向,不考虑阴影与光线的多次反弹。它们共享同一个框架,把片元的输出颜色分解为三个分量之和:

L=La+Ld+LsL = L_a + L_d + L_s

分量 名称 模拟的现象 是否依赖观察方向
LaL_a 环境光(ambient) 光线多次反弹形成的间接光照 否
LdL_d 漫反射(diffuse) 粗糙表面向各个方向均匀散射 否
LsL_s 高光(specular) 光滑表面向镜像方向集中反射 是

两者的区别只在第三项——高光的计算方式:Phong 用反射向量与视线的夹角,Blinn-Phong 用半程向量与法线的夹角。

注:着色与渲染管线 中的公式还包含点光源的距离衰减项 Ir2\frac{I}{r^2},这里把它吸收进光源颜色 lightColor,以便聚焦模型本身。距离衰减与光源类型有关,属于另一个话题。


1.1 环境光

环境光(ambient)是间接光照的粗糙近似:光线在场景中反弹多次之后,近似均匀地照亮物体的每个角落。它与方向无关,用一个常数表示:

La=ka⋅IaL_a = k_a \cdot I_a

其中 kak_a 是环境光系数,IaI_a 是环境光强度。GLSL 里一行就能算出来:

vec3 ambient = ambientStrength * lightColor;

环境光保证背光面不至于全黑,但代价是抹平了明暗对比——强度过高时画面会整体发灰。


1.2 漫反射

漫反射(diffuse reflection)描述粗糙表面的散射:光打进表面后向四面八方均匀射出,因此漫反射分量与观察方向无关——绕着物体走一圈,看到的漫反射颜色不会变。

表面接收到的光量由 Lambert 余弦定律决定(推导见着色与渲染管线):光倾斜照射时,单位面积接收的能量随夹角余弦衰减,而 n⋅l\mathbf{n} \cdot \mathbf{l} 恰好就是这个余弦:

Ld=kd⋅max⁡(0, n⋅l)L_d = k_d \cdot \max(0,\ \mathbf{n} \cdot \mathbf{l})

  • n\mathbf{n}:表面法线,单位向量
  • l\mathbf{l}:从片元指向光源的方向,单位向量
  • kdk_d:漫反射系数,即物体的固有色

max⁡(0,⋅)\max(0, \cdot) 不可省略:当光源位于表面背面(n⋅l<0\mathbf{n} \cdot \mathbf{l} < 0)时,负的点积没有物理意义,钳制为 0 表示背面接收不到光照。


1.3 高光

高光(specular highlight)模拟光滑表面的镜面反射:光集中在镜像方向附近反射,视线越接近镜像方向,看到的高光越亮。

第一步,由光的方向求反射方向。反射向量满足"入射角等于反射角":

r=2(n⋅l) n−l\mathbf{r} = 2(\mathbf{n} \cdot \mathbf{l})\,\mathbf{n} - \mathbf{l}

第二步,用 r\mathbf{r} 与视线方向 v\mathbf{v} 的夹角余弦衡量"接近镜像的程度",再乘上指数 pp:

Ls=ks⋅max⁡(0, r⋅v)pL_s = k_s \cdot \max(0,\ \mathbf{r} \cdot \mathbf{v})^p

  • v\mathbf{v}:从片元指向摄像机的方向,单位向量
  • ksk_s:高光系数,控制高光的颜色与强度,常取偏白的颜色
  • pp:高光指数(shininess),控制高光的集中程度

Phong光线示意图

pp 的影响很直观:余弦值的 pp 次方会加速衰减,pp 越大,视线稍微偏离镜像方向高光就迅速变暗——高光小而锐利,表面显得光滑;pp 越小,高光大而模糊,表面显得粗糙:

pp 高光观感 对应材质
2 又大又模糊 粗糙的塑料
32 适中,常用默认值 一般光滑表面
128 小而锐利 金属、抛光表面

注:标准公式的高光项不包含 n⋅l\mathbf{n} \cdot \mathbf{l},当光源位于表面背面(n⋅l<0\mathbf{n} \cdot \mathbf{l} < 0)而 r⋅v>0\mathbf{r} \cdot \mathbf{v} > 0 时,仍然会产生高光。实践中可以把高光项再乘上一个 max⁡(0,n⋅l)\max(0, \mathbf{n} \cdot \mathbf{l}),保证背光面彻底没有高光。


1.4 GLSL 实现

写代码前先约定向量的方向与空间。所有方向向量都以片元为起点、归一化,并且位于同一个坐标空间(这里选择世界空间):

变量 含义 求法
normal 片元法线 顶点法线插值后归一化
lightDir 片元指向光源 normalize(lightPos - FragPos)
viewDir 片元指向摄像机 normalize(viewPos - FragPos)

注意:方向向量不归一化就参与点积,得到的余弦值是错的——点积的几何意义建立在单位向量之上。

下面把三个分量各写成一个函数,再写一个总装函数。它们依赖的全局 uniform 声明如下:

uniform vec3 lightPos;          // 点光源位置(世界空间)
uniform vec3 viewPos;           // 摄像机位置(世界空间)
uniform vec3 lightColor;        // 光源颜色(光强已吸收进来)
uniform float ambientStrength;  // 环境光强度系数,即公式中的 k_a * I_a
uniform vec3 specColor;         // 高光系数 k_s
uniform float shininess;        // 高光指数 p

环境光函数:

/*
 *  计算环境光贡献
 *
 *  参数:
 *      strength    环境光强度系数,取值 0.0 ~ 1.0
 *      lightColor  光源颜色
 *
 *  返回:
 *      环境光对该片元的 RGB 颜色贡献
 */
vec3 calcAmbient(float strength, vec3 lightColor)
{
    // 环境光与方向无关,直接按强度缩放光源颜色
    return strength * lightColor;
}

漫反射函数:

/*
 *  计算漫反射贡献(Lambert 余弦定律)
 *
 *  参数:
 *      normal      片元法线,已归一化,与 lightDir 位于同一坐标空间
 *      lightDir    从片元指向光源的方向,已归一化
 *      albedo      漫反射系数 k_d,即物体的固有色
 *      lightColor  光源颜色
 *
 *  返回:
 *      漫反射对该片元的 RGB 颜色贡献
 */
vec3 calcDiffuse(vec3 normal, vec3 lightDir, vec3 albedo, vec3 lightColor)
{
    // n 点乘 l:法线与光方向的夹角余弦,衡量单位面积接收的光能
    // max(0.0, ...):光源在表面背面时余弦为负,钳制为 0
    float diff = max(dot(normal, lightDir), 0.0);

    // albedo 决定表面散射出的颜色,与观察方向无关
    return diff * albedo * lightColor;
}

高光函数(Phong 版):

/*
 *  计算高光贡献(Phong 模型)
 *
 *  参数:
 *      normal      片元法线,已归一化
 *      lightDir    从片元指向光源的方向,已归一化
 *      viewDir     从片元指向摄像机的方向,已归一化
 *      specColor   高光系数 k_s,高光的颜色与强度
 *      lightColor  光源颜色
 *      shininess   高光指数 p,越大高光越集中
 *
 *  返回:
 *      高光对该片元的 RGB 颜色贡献
 */
vec3 calcSpecularPhong(vec3 normal, vec3 lightDir, vec3 viewDir,
                       vec3 specColor, vec3 lightColor, float shininess)
{
    // reflect(I, N) 返回入射向量 I 关于法线 N 的反射向量
    // 第一个参数是从光源指向片元的入射方向,与 lightDir 恰好相反,
    // 所以传 -lightDir
    vec3 reflectDir = reflect(-lightDir, normal);

    // r 点乘 v:反射方向与观察方向的夹角余弦,越接近 1 高光越亮
    // pow(..., shininess):指数衰减,p 越大高光越集中
    float spec = pow(max(dot(reflectDir, viewDir), 0.0), shininess);

    return spec * specColor * lightColor;
}

注意:GLSL 的内置函数 reflect(I, N) 要求第一个参数是指向表面的入射向量(从光源指向片元)。而 lightDir 的约定方向恰好相反(片元指向光源),所以必须传 -lightDir,否则反射向量会指向表面内部。

总装函数:

/*
 *  Phong 光照:环境光 + 漫反射 + 高光三项求和
 *
 *  参数:
 *      normal   片元法线(世界空间,已归一化)
 *      fragPos  片元位置(世界空间)
 *      albedo   漫反射系数 k_d(物体固有色)
 *
 *  返回:
 *      该片元的最终颜色(RGB)
 *
 *  依赖全局 uniform:lightPos、viewPos、lightColor、
 *  ambientStrength、specColor、shininess
 */
vec3 phongLighting(vec3 normal, vec3 fragPos, vec3 albedo)
{
    // 片元指向光源 / 摄像机的方向向量
    vec3 lightDir = normalize(lightPos - fragPos);
    vec3 viewDir  = normalize(viewPos - fragPos);

    vec3 ambient  = calcAmbient(ambientStrength, lightColor);
    vec3 diffuse  = calcDiffuse(normal, lightDir, albedo, lightColor);
    vec3 specular = calcSpecularPhong(normal, lightDir, viewDir,
                                      specColor, lightColor, shininess);

    return ambient + diffuse + specular;
}

2. Blinn-Phong 模型

Blinn-Phong 模型由 Jim Blinn 于 1977 年提出,它完全保留了 Phong 的环境光与漫反射,只修改高光项的计算方式。修改的原因是 Phong 高光存在一个肉眼可见的缺陷。


2.1 Phong 高光的缺陷

Phong 高光项里的 max⁡(0,r⋅v)\max(0, \mathbf{r} \cdot \mathbf{v}) 在掠射视角下会出问题。考虑光源低角度掠过表面、视线接近水平的情形:

当 l\mathbf{l} 与 v\mathbf{v} 的夹角超过 90° 时,反射向量 r\mathbf{r} 与 v\mathbf{v} 的夹角只会更大——点积为负,被钳制成 0。问题在于这个归零是突变的:视线越过这条界线的瞬间,高光从某个亮度直接跌落到 0,在平坦表面上留下一道生硬的亮暗分界。高光越大(pp 越小),这道分界越明显。


2.2 半程向量

Blinn 的方案是绕开反射向量,改用半程向量(halfway vector)——l\mathbf{l} 与 v\mathbf{v} 的角平分线方向:

h=l+v∥l+v∥\mathbf{h} = \frac{\mathbf{l} + \mathbf{v}}{\lVert \mathbf{l} + \mathbf{v} \rVert}

Blinn-Phong光线示意图

几何意义:若 n\mathbf{n} 与 h\mathbf{h} 对齐,则 l\mathbf{l} 与 v\mathbf{v} 关于 n\mathbf{n} 对称——视线恰好位于镜像方向上。于是高光项改为:

Ls=ks⋅max⁡(0, n⋅h)pL_s = k_s \cdot \max(0,\ \mathbf{n} \cdot \mathbf{h})^p

为什么这能修复缺陷:h\mathbf{h} 是 l\mathbf{l} 与 v\mathbf{v} 的角平分线,它与两者的夹角都只有 l\mathbf{l}、v\mathbf{v} 夹角的一半。即使 l\mathbf{l} 与 v\mathbf{v} 的夹角超过 90°,h\mathbf{h} 与 n\mathbf{n} 的夹角依然可以很小,n⋅h\mathbf{n} \cdot \mathbf{h} 连续变化——高光平滑衰减到 0,不会出现生硬的截断。


2.3 GLSL 实现

与 Phong 的差别只是高光函数:

/*
 *  计算高光贡献(Blinn-Phong 模型)
 *
 *  参数与返回值同 calcSpecularPhong:
 *      normal      片元法线,已归一化
 *      lightDir    从片元指向光源的方向,已归一化
 *      viewDir     从片元指向摄像机的方向,已归一化
 *      specColor   高光系数 k_s
 *      lightColor  光源颜色
 *      shininess   高光指数 p
 */
vec3 calcSpecularBlinnPhong(vec3 normal, vec3 lightDir, vec3 viewDir,
                            vec3 specColor, vec3 lightColor, float shininess)
{
    // 半程向量:l 与 v 相加后归一化,即两者的角平分线方向
    vec3 halfwayDir = normalize(lightDir + viewDir);

    // n 点乘 h:法线与半程向量的夹角余弦
    // n 与 h 对齐时,v 恰好位于光的镜像反射方向上
    float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);

    return spec * specColor * lightColor;
}

总装时把 phongLighting 中对 calcSpecularPhong 的调用换成 calcSpecularBlinnPhong,其余一行都不用改。


2.4 模型对比

Phong Blinn-Phong
高光核心量 max⁡(0, r⋅v)p\max(0,\ \mathbf{r} \cdot \mathbf{v})^p max⁡(0, n⋅h)p\max(0,\ \mathbf{n} \cdot \mathbf{h})^p
关键 GLSL 调用 reflect(-lightDir, normal) normalize(lightDir + viewDir)
l\mathbf{l} 与 v\mathbf{v} 夹角超 90° 高光突变截断,留下生硬分界 平滑衰减,无截断
相近观感的 shininess pp 约取 2p∼4p2p \sim 4p

同样的 pp 值下,两个模型的高光大小并不一致:n⋅h\mathbf{n} \cdot \mathbf{h} 与 r⋅v\mathbf{r} \cdot \mathbf{v} 的衰减速度不同——光正对表面时,Blinn-Phong 的高光大约是 Phong 的两倍宽;掠射角下则自然地变小变暗,更接近真实材质的行为。要得到与 Phong 接近的高光观感,Blinn-Phong 的 pp 通常取 2~4 倍:Phong 用 32 时,Blinn-Phong 取 64~128。


3. 完整着色器示例


3.1 坐标空间与法线矩阵

光照计算要求所有向量位于同一个坐标空间,这里选世界空间:lightPos、viewPos 本来就用世界坐标描述,片元位置 FragPos 由 model 矩阵变换得到。

法线的变换有一个陷阱:不能直接用 mat3(model) 变换法线。旋转和等比缩放没问题,但非等比缩放会破坏法线与表面的垂直关系。正确做法是用正规矩阵(normal matrix,model 矩阵左上 3×3 的逆转置):

Normal = mat3(transpose(inverse(model))) * aNormal;

注:逆矩阵再转置能保证变换后的法线仍然垂直于变换后的表面。每个顶点都算一次 inverse 开销不小,如果模型的变换不含非等比缩放,直接用 mat3(model) 也可以。


3.2 顶点着色器

#version 330 core

layout (location = 0) in vec3 aPos;      // 顶点位置(模型空间)
layout (location = 1) in vec3 aNormal;   // 顶点法线(模型空间)

uniform mat4 model;       // 模型变换矩阵
uniform mat4 view;        // 观察矩阵
uniform mat4 projection;  // 投影矩阵

out vec3 FragPos;         // 片元位置(世界空间)
out vec3 Normal;          // 片元法线(世界空间)

void main()
{
    // MVP 变换,得到裁剪空间坐标
    gl_Position = projection * view * model * vec4(aPos, 1.0);

    // 位置变换到世界空间
    FragPos = vec3(model * vec4(aPos, 1.0));

    // 法线用正规矩阵变换(见上一节的说明)
    Normal = mat3(transpose(inverse(model))) * aNormal;
}

3.3 片段着色器

完整的 Blinn-Phong 片段着色器,可以直接使用。把 calcSpecularBlinnPhong 换成 calcSpecularPhong 就是 Phong 版本:

#version 330 core

in vec3 FragPos;    // 从顶点着色器插值而来:世界空间位置
in vec3 Normal;     // 从顶点着色器插值而来:世界空间法线

uniform vec3 lightPos;          // 点光源位置(世界空间)
uniform vec3 viewPos;           // 摄像机位置(世界空间)
uniform vec3 lightColor;        // 光源颜色
uniform float ambientStrength;  // 环境光强度系数 k_a * I_a
uniform vec3 specColor;         // 高光系数 k_s
uniform float shininess;        // 高光指数 p
uniform vec3 albedo;            // 漫反射系数 k_d(物体固有色)

out vec4 FragColor;             // 输出的片元颜色

vec3 calcAmbient(float strength, vec3 lightColor)
{
    return strength * lightColor;
}

vec3 calcDiffuse(vec3 normal, vec3 lightDir, vec3 albedo, vec3 lightColor)
{
    float diff = max(dot(normal, lightDir), 0.0);
    return diff * albedo * lightColor;
}

vec3 calcSpecularBlinnPhong(vec3 normal, vec3 lightDir, vec3 viewDir,
                            vec3 specColor, vec3 lightColor, float shininess)
{
    vec3 halfwayDir = normalize(lightDir + viewDir);
    float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
    return spec * specColor * lightColor;
}

void main()
{
    // 插值会破坏单位长度,先归一化再参与点积
    vec3 normal   = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    vec3 viewDir  = normalize(viewPos - FragPos);

    vec3 ambient  = calcAmbient(ambientStrength, lightColor);
    vec3 diffuse  = calcDiffuse(normal, lightDir, albedo, lightColor);
    vec3 specular = calcSpecularBlinnPhong(normal, lightDir, viewDir,
                                           specColor, lightColor, shininess);

    FragColor = vec4(ambient + diffuse + specular, 1.0);
}

3.4 uniform 的设置

C++ 侧在渲染循环中把这些 uniform 传进去。材质参数初始化一次即可,viewPos、lightPos 随摄像机与光源移动,每帧更新:

glUseProgram(shaderProgram);

// 材质参数(初始化时设置一次)
glUniform3f(glGetUniformLocation(shaderProgram, "lightColor"), 1.0f, 1.0f, 1.0f);
glUniform1f(glGetUniformLocation(shaderProgram, "ambientStrength"), 0.1f);
glUniform3f(glGetUniformLocation(shaderProgram, "specColor"), 0.5f, 0.5f, 0.5f);
glUniform1f(glGetUniformLocation(shaderProgram, "shininess"), 64.0f);
glUniform3f(glGetUniformLocation(shaderProgram, "albedo"), 1.0f, 0.5f, 0.31f);

// 每帧更新
glUniform3fv(glGetUniformLocation(shaderProgram, "viewPos"), 1, &cameraPos[0]);
glUniform3fv(glGetUniformLocation(shaderProgram, "lightPos"), 1, &lightPos[0]);

注:把 albedo 换成纹理采样(texture(material.diffuse, TexCoords))就是带贴图的光照,思路与纹理中"应用纹理"一节完全一致。