一、什么是光照?
都是经过栅格化、纹理贴图以及插值之后,每个像素都变为了设置好的颜色。现在我们需要加入一个新的步骤来调整这些栅格化之后的像素颜色,以便反应场景中的光照和材质。
在计算机图形学中,光照(Lighting)是指通过模拟光线与物体表面的相互作用,计算物体在场景中的颜色和亮度,从而让三维物体呈现出更加真实的视觉效果。
在没有光照计算的情况下,物体通常只能按照预先指定的颜色进行显示,难以表现出物体表面的明暗变化、立体感以及材质差异。
例如,同一个白色球体,在不同光照条件下可能呈现出完全不同的效果:
-
在较强的光照下,球体会显得明亮。
-
在光线较弱的区域,球体会显得暗淡。
-
当光线从某个方向照射球体时,球体表面会产生明暗变化。
-
当光线照射到具有光泽的表面时,还可能产生明显的高光。
这些现象都可以通过光照模型进行近似模拟。
因此,光照计算的核心目标是:根据光源、物体表面属性以及观察方向,计算物体表面各个位置的光照结果。
在实时渲染中,光照通常与模型变换、光栅化、纹理映射和深度测试等环节配合使用,共同构成完整的渲染流程。
二、理解光照计算中的几个基本向量
在学习具体的光照模型之前,先认识几个重要的向量。
1. 表面法线 N
法线是垂直于表面的方向向量,通常记作 N。
例如,对于一个球体:
-
球体最上方的点,法线大致朝上。
-
球体最右侧的点,法线大致朝右。
-
球体最前方的点,法线朝向球体表面的外侧。
法线决定了表面朝向哪个方向,是计算光照的基础。
在常见的光照计算中,法线通常会被归一化,使其长度为 1。
2. 光照方向 L
L 表示从表面指向光源的单位向量。
假设光源位于物体表面的右上方,那么 L 就指向右上方。
这里需要注意:在一些图形学资料中,光线的传播方向也可能用 L 表示。不同资料的符号约定可能不同,因此阅读公式时一定要先确认向量的定义。
本文统一采用 L 表示从表面指向光源的方向。
3. 观察方向 V
V 表示从表面指向摄像机或观察者的单位向量。
它主要用于计算镜面反射、高光等与观察角度有关的效果。
4. 点积 N · L
点积(Dot Product)是光照计算中最常用的数学运算之一。
对于两个单位向量,它们的点积等于夹角余弦:
N · L = cos θ
其中,θ 是法线 N 与光照方向 L 之间的夹角。
它的意义非常直观:
-
当 N 与 L 完全同向时,点积为 1,表面正对光源。
-
当两者夹角为 60° 时,点积为 0.5。
-
当两者互相垂直时,点积为 0。
-
当夹角大于 90° 时,点积为负数,说明表面背对光源。
由于背对光源的表面通常不应该接收到来自该光源的直接漫反射,因此我们经常使用:
max(N · L, 0)
这个操作会将负值截断为 0。
理解了点积,就掌握了很多基础光照模型的核心。
三、环境光、漫反射与镜面反射
传统实时渲染教学中,常用环境光、漫反射和镜面反射三个部分来帮助理解物体的明暗与高光。
最后所呈现出来的也就是三个光照相加出来的结果。
1、环境光
环境光分量的值很容易计算,为场景环境光与材质环境光分量的乘积:
I_ambient = k_a × I_a
其中:
-
I_ambient:环境光的贡献。
-
k_a:材质的环境光系数。
-
I_a:环境光强度。
2、漫反射
公式:
I_diffuse = k_d × I_L × max(N · L, 0)

其中:
-
k_d:材质的漫反射系数。
-
I_L:光源的强度或颜色。
-
N:表面法线单位向量。
-
L:从表面指向光源的单位向量。
-
N · L:法线与光照方向的点积,等于两者夹角的余弦。
-
max(N · L, 0):将负值截断为 0,避免背对光源的表面产生负的直接漫反射贡献。
为什么漫反射要使用点积?
因为物体表面的朝向不同,接收到的直接光照就不同。为了计算表面朝向对光照的影响,我们使用表面法线向量 N 与光照方向向量 L 的点积。
点积公式:
N · L = cos(θ)
其中:
-
N:表面法线单位向量。
-
L:从表面指向光源的单位向量。
-
θ:法线 N 与光照方向 L 之间的夹角。
由于 N 和 L 都是单位向量,因此它们的点积等于夹角的余弦值。
不同角度下的点积结果:
-
表面正对光源
θ = 0°
N · L = cos(0°) = 1
此时表面正对光源,光照因子最大,漫反射贡献最大。
-
表面倾斜 60°
θ = 60°
N · L = cos(60°) = 0.5
此时光照因子为 0.5,漫反射贡献为正对光源时的一半。
-
表面背对光源
θ = 120°
N · L = cos(120°) = -0.5
此时点积为负数,说明表面背对光源。为了避免产生负的直接漫反射贡献,我们使用 max(N · L, 0) 将负值截断为 0。
3、镜面反射
公式:
I_specular = k_s × I_L × max(R · V, 0)^n

其中:
-
k_s:材质的镜面反射系数。
-
I_L:光源的强度或颜色。
-
R:光线关于表面法线的反射方向。
-
V:从表面指向观察者的单位向量。
-
n:高光指数,数值越大,高光通常越集中。
反射方向 R 的计算公式:
R = 2(N · L)N - L
这里假设 N 和 L 都是单位向量,且 L 指向光源。
镜面反射也就是高光的显示,当反射光离我们眼睛角度越近的话,高光就应该越明显,当角度比较大的话,高光就应该逐渐的消失。
为什么要有一个n次方
后续的R和V知识表示一个方向,所以都会进行归一化,R · V也就变成了cosθ,看下图cosθ的表示,当0-90°的时候,都会有高光的出现,但是在现实中,高光往往只有一小块的出现,所以应该取cosθ的n次方,这个n通常要取100-200°之间,通常0-5°之间才会有高光的出现。

ps:有学习过openGL的可能会发现一些问题,就是在opengl中,在OpenGL里面,将材质光系数换成了下面的
Material spec
这两个表示的是同一个东西。
四、三个光照着色方法
一、前置知识
在学习环境光、漫反射和镜面反射之前,我们需要先了解几个重要概念:Shader(着色器)、顶点着色器、片元着色器,以及法向量在渲染管线中的处理方式。
这些知识决定了光照计算在哪里执行、参与计算的数据如何传递,以及为什么同一种光照模型可以有不同的实现方式。
1、什么是 Shader?
Shader,中文通常称为着色器,是运行在 GPU 上的小程序,用于控制图形渲染过程中的某些计算。
在现代 OpenGL 中,我们通常使用 GLSL(OpenGL Shading Language)编写着色器。
例如,我们可以编写 Shader 来完成以下工作:
-
计算顶点在屏幕上的位置。
-
传递顶点的法向量、颜色和纹理坐标。
-
根据光源、材质和观察方向计算光照。
-
计算片元最终输出的颜色。
在现代 OpenGL 的可编程渲染管线中,最常接触的两种 Shader 是:
-
Vertex Shader(顶点着色器)
-
Fragment Shader(片元着色器)
它们负责不同阶段的工作,因此光照计算也可以放在不同的阶段执行。
2、顶点着色器(Vertex Shader)
顶点着色器负责处理输入的顶点数据。
假设我们要绘制一个三角形,它由三个顶点组成。每个顶点除了位置之外,还可以携带其他属性,例如:
-
顶点位置(Position)
-
法向量(Normal)
-
颜色(Color)
-
纹理坐标(Texture Coordinate)
这些数据通常由 CPU 端的 OpenGL 程序通过顶点缓冲区等方式传递给 GPU。
顶点着色器会针对输入的每个顶点执行一次,完成顶点级别的计算。
例如,最基本的顶点着色器需要将顶点位置转换到裁剪空间:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
其中:
-
aPos:输入的顶点位置。 -
model:模型矩阵,用于将顶点从模型空间变换到世界空间。 -
view:观察矩阵,用于将顶点转换到观察空间。 -
projection:投影矩阵,用于将顶点转换到裁剪空间。 -
gl_Position:顶点着色器输出的裁剪空间位置。
在光照计算中,顶点着色器还可以处理法向量、光照方向等数据,甚至直接计算每个顶点的光照结果。
因此,顶点着色器不仅负责位置变换,也可以参与光照计算。
3、片元着色器(Fragment Shader)
片元着色器负责处理光栅化阶段产生的片元(Fragment),通常用于计算最终输出的颜色。
这里需要区分两个概念:
-
像素(Pixel):屏幕上的像素位置。
-
片元(Fragment):光栅化过程中产生的、可能影响某个像素的候选数据,通常包含插值后的属性等信息。
片元和最终写入的像素并不是严格的一一对应关系,因为深度测试、模板测试、混合等操作可能影响最终结果。
例如,下面是一个简单的片元着色器:
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
它会输出红色。
在光照计算中,片元着色器也可以接收法向量、光照方向、观察方向、材质颜色等数据,并根据光照模型计算当前片元的颜色。
二、Gouraud 着色(双线性光强插值法)
Gouraud 着色(Gouraud Shading)是一种经典的平滑着色方法,由法国计算机科学家 Henri Gouraud 在 1971 年提出。
在三维图形中,一个物体通常由大量三角形组成。如果直接为每个三角形计算一个固定的颜色,那么相邻三角形之间可能出现明显的颜色边界,使物体看起来像由许多独立的平面拼接而成。
Gouraud 着色通过对顶点颜色进行插值,让三角形内部的颜色逐渐变化,从而使物体呈现更加平滑的明暗效果。
1. Gouraud 着色的基本流程
Gouraud 着色可以分为两个主要步骤。
第一步:在每个顶点处计算光照颜色。
首先,根据模型提供的顶点位置、顶点法向量,以及光源和材质等信息,在每个顶点处执行光照计算。
第二步:对顶点颜色进行插值。
完成顶点光照计算后,GPU 会继续执行光栅化过程。在三角形覆盖的区域内,GPU 根据顶点颜色对内部片元的颜色进行插值。
例如,假设三角形的三个顶点分别计算出了红色、绿色和蓝色,那么三角形内部不同位置的片元就会获得相应的插值颜色。
因此,三角形内部不需要再次完整计算光照模型,而是可以直接使用插值后的光照颜色。
整个过程可以概括为:
顶点位置和法向量 → 顶点着色器计算光照 → 得到顶点颜色 → 光栅化时插值颜色 → 片元着色器输出颜色
2. 顶点法线的计算方法
一个顶点周围有很多三角形,计算这个顶点的法线时,让面积大的三角形多说几句话,面积小的三角形少说几句话。最后把大家的意见综合起来,得到一个方向。

公式到底在做什么?
原来的公式可以拆成三步:
-
每个三角形的法线乘以自己的面积。 面积越大,这个法线的影响就越大。
-
把所有结果加起来。 相当于把各个三角形的法线方向综合起来,再除以三角形的数量。
-
归一化。 把最后得到的向量长度调整为 1,只保留方向。
总结:所以Gouraud也就是在顶点着色器中就根据顶点的法线计算出了顶点的颜色,之后经过光栅化给每一个采样点插值,最后输出每一个像素的颜色。
3. OpenGL代码演示
顶点着色器
cpp
//顶点着色器
#version 430
layout (location=0) in vec3 vertPos;
layout (location=1) in vec3 vertNormal;
out vec4 varyingColor;
//点光源属性
struct PositionalLight
{
vec4 ambient; //环境光颜色
vec4 diffuse; //漫反射颜色
vec4 specular; //镜面反射颜色
vec3 position; //光源位置
};
struct Material
{
vec4 ambient; //材质的环境光反射颜色
vec4 diffuse; //材质的漫反射颜色
vec4 specular; //材质的镜面反射颜色
float shininess; //高光指数
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;//转换法向量
void main(void)
{
vec4 color;
//将顶点位置转换到视觉空间
vec4 P=mv_matrix*vec4(vertPos,1.0);
//将法向量转换到视觉空间(使用norm_matrix)
vec3 N=normalize((norm_matrix*vec4(vertNormal,1.0)).xyz);
//计算视觉空间光照向量(从顶点到光源)
vec3 L=normalize(light.position-P.xyz);
//视觉向量等于视觉空间中的负顶点位置(顶点到眼睛)
vec3 V=normalize(-P.xyz);
//-L也就是从光源到顶点的方向,N为法线,R就是反射光的方向
vec3 R=reflect(-L,N);
//环境光,漫反射和镜面反射分量
vec3 ambient=((globalAmbient*material.ambient)+(light.ambient*material.ambient)).xyz;
vec3 diffuse=light.diffuse.xyz*material.diffuse.xyz*max(dot(N,L),0.0);
vec3 specular=material.specular.xyz*light.specular.xyz*pow(max(dot(R,V),0.0f),material.shininess);
//将颜色输出发送到片段着色器
varyingColor=vec4((ambient+diffuse+specular),1.0);
//将位置发送到片段着色器
gl_Position=proj_matrix*mv_matrix*vec4(vertPos,1.0);
}
片元着色器
cpp
//片段着色器
#version 430
in vec4 varyingColor;
out vec4 fragColor;
// 与顶点着色器相同的统一变量
// 但并不直接在当前片段着色器使用
struct PositionalLight
{
vec4 ambient;
vec4 diffuse;
vec4 specular;
vec3 position;
};
struct Material
{
vec4 ambient;
vec4 diffuse;
vec4 specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
void main(void)
{ fragColor = varyingColor;
}
三、Phong 着色
1. Phong 着色的基本流程
第一步:计算顶点法线
与 Gouraud 一样,先把顶点周围相邻面的法线进行平均,再归一化:
第二步:对顶点法向量进行插值
这一步是光栅化做的,也就是GPU帮我们做的,在顶点着色器计算出每个顶点的法向量,传递给判断着色器,在传递的过程中,GPU会通过三角形的顶点对每一个采样点进行插值求出每一个采样点的法向量。
第三步:在片元着色器中计算像素的颜色
使用插值得到的像素法线,在片元着色器中计算每一个像素的漫反射和镜面反射等光照。
2. OpenGL代码演示
顶点着色器
cpp
//顶点着色器
#version 430
layout (location=0) in vec3 vertPos;
layout (location=1) in vec3 vertNormal;
out vec3 varyingNormal; //视觉空间顶点法向量
out vec3 varyingLightDir; //指向光源的向量
out vec3 varyingVeryPos; //视觉空间中的顶点位置
//点光源属性
struct PositionalLight
{
vec4 ambient; //环境光颜色
vec4 diffuse; //漫反射颜色
vec4 specular; //镜面反射颜色
vec3 position; //光源位置
};
struct Material
{
vec4 ambient; //材质的环境光反射颜色
vec4 diffuse; //材质的漫反射颜色
vec4 specular; //材质的镜面反射颜色
float shininess; //高光指数
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;//转换法向量
void main(void)
{
//输出顶点位置,光照方向和法向量到光栅着色器以进行插值
varyingVeryPos=(mv_matrix*vec4(vertPos,1.0)).xyz;
varyingLightDir=light.position-varyingVeryPos;
varyingNormal=(norm_matrix*vec4(vertNormal,1.0)).xyz;
//将位置发送到片段着色器
gl_Position=proj_matrix*mv_matrix*vec4(vertPos,1.0);
}
片元着色器
cpp
//片段着色器
#version 430
in vec3 varyingNormal;
in vec3 varyingLightDir;
in vec3 varyingVeryPos;
out vec4 fragColor;
// 与顶点着色器相同的统一变量
// 但并不直接在当前片段着色器使用
struct PositionalLight
{
vec4 ambient;
vec4 diffuse;
vec4 specular;
vec3 position;
};
struct Material
{
vec4 ambient;
vec4 diffuse;
vec4 specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
void main(void)
{
//归一化光照向量,法向量,视觉向量
vec3 L=normalize(varyingLightDir);
vec3 N=normalize(varyingNormal);
vec3 V=normalize(-varyingVeryPos);
//计算光照向量基于N的反射向量
vec3 R=normalize(reflect(-L,N));
//计算光照和平面法向量之间的角度
float cosTheta=dot(L,N);
//计算视觉向量和反射光向量的角度
float cosPhi=dot(V,R);
//计算ADS分量(按像素),并合并以构建输出颜色
vec3 ambient=((globalAmbient*material.ambient)+(light.ambient*material.ambient)).xyz;
vec3 diffuse=light.diffuse.xyz*material.diffuse.xyz*max(cosTheta,0.0);
vec3 specular=light.specular.xyz*material.specular.xyz*pow(max(cosPhi,0.0),material.shininess);
fragColor=vec4((ambient+diffuse+specular),1.0);
}
四、Blinn-Phong 着色
1. Blinn-Phong 着色的基本流程
Blinn-Phong 着色其实也就是对Phong着色的优化。

Blinn 发现向量 R在计算过程中并不是必需的------R只是用来计算角 φ的手段。角 φ的计算
可以不使用向量 R,而通过 L与 V的角平分线向量 H得到。如图 7.13 所示,H和 N之间的角 α
刚好等于 1/2(φ)。虽然 α与 φ不同,但 Blinn 展示了使用 α代替 φ就已经可以获得足够好的结果。
2. OpenGL代码演示
顶点着色器
cpp
//顶点着色器
#version 430
layout (location=0) in vec3 vertPos;
layout (location=1) in vec3 vertNormal;
out vec3 varyingNormal; //视觉空间顶点法向量
out vec3 varyingLightDir; //指向光源的向量
out vec3 varyingVeryPos; //视觉空间中的顶点位置
//相较于Phong,增加角平分线H为新增的输出
out vec3 varyingHalfVector;
//点光源属性
struct PositionalLight
{
vec4 ambient; //环境光颜色
vec4 diffuse; //漫反射颜色
vec4 specular; //镜面反射颜色
vec3 position; //光源位置
};
struct Material
{
vec4 ambient; //材质的环境光反射颜色
vec4 diffuse; //材质的漫反射颜色
vec4 specular; //材质的镜面反射颜色
float shininess; //高光指数
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;//转换法向量
void main(void)
{
//输出顶点位置,光照方向和法向量到光栅着色器以进行插值
varyingVeryPos=(mv_matrix*vec4(vertPos,1.0)).xyz;
varyingLightDir=light.position-varyingVeryPos;
varyingNormal=(norm_matrix*vec4(vertNormal,1.0)).xyz;
//增加L+V的计算
varyingHalfVector=(varyingLightDir+(-varyingVeryPos)).xyz;
//将位置发送到片段着色器
gl_Position=proj_matrix*mv_matrix*vec4(vertPos,1.0);
}
片元着色器
cpp
//片段着色器
#version 430
in vec3 varyingNormal;
in vec3 varyingLightDir;
in vec3 varyingVeryPos;
in vec3 varyingHalfVector;
out vec4 fragColor;
// 与顶点着色器相同的统一变量
// 但并不直接在当前片段着色器使用
struct PositionalLight
{
vec4 ambient;
vec4 diffuse;
vec4 specular;
vec3 position;
};
struct Material
{
vec4 ambient;
vec4 diffuse;
vec4 specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
void main(void)
{
//归一化光照向量,法向量,视觉向量,
vec3 L=normalize(varyingLightDir);
vec3 N=normalize(varyingNormal);
vec3 V=normalize(-varyingVeryPos);
vec3 H=normalize(varyingHalfVector);
//计算光照和平面法向量之间的角度
float cosTheta=dot(L,N);
//计算法向量N与角平分线向量H之间的角度
float cosPhi=dot(H,N);
//计算ADS分量(按像素),并合并以构建输出颜色
vec3 ambient=((globalAmbient*material.ambient)+(light.ambient*material.ambient)).xyz;
vec3 diffuse=light.diffuse.xyz*material.diffuse.xyz*max(cosTheta,0.0);
vec3 specular=light.specular.xyz*material.specular.xyz*pow(max(cosPhi,0.0),material.shininess*3.0);
fragColor=vec4((ambient+diffuse+specular),1.0);
}