Skip to main content

12. 光照模型实战:从 Lambert 到 Phong

光照是 3D 图形学的灵魂。没有光照,所有的物体看起来都是平面的色块。 本章我们将从零开始,一步步实现最经典、最常用的 Phong 光照模型

1. 拆解光照:三个核心成分

在经典图形学中,我们把光照简化为三个部分的叠加:

  1. 环境光 (Ambient):基础底色,模拟光线的散射,保证暗部不全是黑的。
  2. 漫反射 (Diffuse):模拟粗糙表面的反光,光线越垂直于表面越亮。
  3. 镜面光 (Specular):模拟光滑表面的高光,取决于观察角度。

$$ \text{最终颜色} = \text{环境光} + \text{漫反射} + \text{镜面光} $$


2. 准备工作:所需变量

要计算光照,我们需要以下几个关键向量(都需要在 Fragment Shader 中):

  1. vNormal (N):法线,决定面朝哪里。
  2. lightDir (L):光线方向,光从哪里来。
  3. viewDir (V):视线方向,相机在哪里(用于计算高光)。

⚠️ 重要提示:所有参与点积 (dot) 计算的向量,必须先归一化 (normalize)


3. 第一步:环境光 (Ambient)

这是最简单的。它就是一个常数颜色。

void main() {
vec3 lightColor = vec3(1.0, 1.0, 1.0); // 白光
float ambientStrength = 0.1; // 环境光强度 (很弱)

vec3 ambient = ambientStrength * lightColor;

// ...
gl_FragColor = vec4(ambient * objectColor, 1.0);
}

4. 第二步:漫反射 (Diffuse / Lambert)

核心原理

  • 如果光线垂直射向表面(夹角 0 度),最亮。
  • 如果光线擦着表面过去(夹角 90 度),没光。
  • 如果光线从背面射来(夹角 > 90 度),全黑。

数学工具是 点积 (Dot Product)dot(N, L) 等于 cos(夹角)

// 在 Fragment Shader 中
void main() {
// 1. 归一化法线和光线方向
vec3 norm = normalize(vNormal);
vec3 lightDir = normalize(uLightPos - vFragPos); // 光源位置 - 片段位置

// 2. 计算点积
// max(..., 0.0) 是为了剔除背面的光(点积为负数的情况)
float diff = max(dot(norm, lightDir), 0.0);

vec3 diffuse = diff * lightColor;

// ...
}

5. 第三步:镜面光 (Specular / Phong)

核心原理

  • 像镜子一样,光线射进来,会沿着反射方向射出去。
  • 如果你的眼睛(相机)刚好在反射光的方向上,你就会看到刺眼的高光

你需要计算两个向量的夹角:反射光方向 (R)视线方向 (V)

void main() {
float specularStrength = 0.5; // 高光强度

// 1. 计算视线方向 (V)
vec3 viewDir = normalize(uCameraPos - vFragPos);

// 2. 计算反射方向 (R)
// reflect 函数要求入射光是指向表面的,所以用 -lightDir
vec3 reflectDir = reflect(-lightDir, norm);

// 3. 计算高光系数
// dot(viewDir, reflectDir) 计算视线和反射光的夹角
// pow(..., 32.0) 是"反光度" (Shininess)。值越大,光斑越小越锐利。
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);

vec3 specular = specularStrength * spec * lightColor;

// ...
}

6. 最终组合 (The Complete Code)

把它们加在一起,就是著名的 Phong 光照模型。

// --- Vertex Shader ---
varying vec3 vNormal;
varying vec3 vFragPos; // 世界空间中的位置

void main() {
// 将法线变换到世界空间 (注意:如果有非均匀缩放,需用 normalMatrix)
vNormal = mat3(modelMatrix) * normal;

// 计算世界空间坐标,传给 Fragment Shader 用于计算光线方向
vFragPos = vec3(modelMatrix * vec4(position, 1.0));

gl_Position = projectionMatrix * viewMatrix * vec4(vFragPos, 1.0);
}

// --- Fragment Shader ---
uniform vec3 uLightPos; // 光源位置
uniform vec3 uCameraPos; // 相机位置 (camera.position)
uniform vec3 uObjectColor;// 物体颜色

varying vec3 vNormal;
varying vec3 vFragPos;

void main() {
// 0. 基础准备
vec3 lightColor = vec3(1.0);
vec3 norm = normalize(vNormal);
vec3 lightDir = normalize(uLightPos - vFragPos);

// 1. 环境光 (Ambient)
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;

// 2. 漫反射 (Diffuse)
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;

// 3. 镜面光 (Specular)
float specularStrength = 0.5;
vec3 viewDir = normalize(uCameraPos - vFragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); // 32.0 是反光度
vec3 specular = specularStrength * spec * lightColor;

// 4. 最终合并
vec3 result = (ambient + diffuse + specular) * uObjectColor;
gl_FragColor = vec4(result, 1.0);
}

7. Blinn-Phong:一个更快的改进

在早期的硬件上,计算 reflect 函数比较慢。Jim Blinn 提出了一种改进方法:Blinn-Phong

它不计算反射光,而是计算 "半程向量" (Halfway Vector)

  • 半程向量 H = 光线方向 L + 视线方向 V 的角平分线。
  • 原理:如果 H 和法线 N 很接近,说明视线和反射光也很接近。
// Blinn-Phong 的镜面光计算
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(norm, halfwayDir), 0.0), 32.0);

优势

  1. 计算量略小(只有加法和归一化)。
  2. 高光看起来更自然、更柔和。
  3. Three.js 的 MeshPhongMaterial 默认使用的其实是 Blinn-Phong!

📝 编程作业

难度:⭐⭐⭐ 困难 | 预计时间:90 分钟

任务要求

实现完整的 Phong 光照模型,这是图形学的核心技能。

具体要求

  1. 环境光:实现基础的环境光
  2. Lambert 漫反射:实现基础的漫反射光照
  3. Phong 镜面高光:添加镜面反射高光
  4. Blinn-Phong 改进:可选,改进为 Blinn-Phong 模型

代码框架

// 顶点着色器
precision mediump float;
precision mediump int;

uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
uniform mat3 normalMatrix;

attribute vec3 position;
attribute vec3 normal;

varying vec3 vNormal;
varying vec3 vFragPos;

void main() {
// 计算世界空间位置和法线
vFragPos = vec3(modelMatrix * vec4(position, 1.0));
vNormal = normalize(normalMatrix * normal);

gl_Position = projectionMatrix * viewMatrix * vec4(vFragPos, 1.0);
}

// 片段着色器
precision mediump float;

uniform vec3 uLightPos;
uniform vec3 uCameraPos;
uniform vec3 uObjectColor;

varying vec3 vNormal;
varying vec3 vFragPos;

void main() {
vec3 lightColor = vec3(1.0);
vec3 norm = normalize(vNormal);
vec3 lightDir = normalize(uLightPos - vFragPos);

// TODO 1: 环境光
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;

// TODO 2: 漫反射
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;

// TODO 3: 镜面光
float specularStrength = 0.5;
vec3 viewDir = normalize(uCameraPos - vFragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
vec3 specular = specularStrength * spec * lightColor;

// 合并所有光照
vec3 result = (ambient + diffuse + specular) * uObjectColor;
gl_FragColor = vec4(result, 1.0);
}

JavaScript 部分

const geometry = new THREE.SphereGeometry(1, 32, 32);

const uniforms = {
uLightPos: { value: new THREE.Vector3(2, 2, 2) },
uCameraPos: { value: camera.position },
uObjectColor: { value: new THREE.Color(1.0, 0.5, 0.31) }
};

// 在动画循环中更新相机位置
uniforms.uCameraPos.value.copy(camera.position);

检查清单

  • 环境光正确显示(暗部不是全黑)
  • 漫反射有明显的明暗变化
  • 镜面高光清晰可见
  • 所有向量都正确归一化
  • 光照效果随视角变化

扩展挑战

  • 🌟 实现多光源支持(2-3 个光源)
  • 🌟 添加光源颜色控制
  • 🌟 实现可交互的光源位置(鼠标控制)
  • 🌟 改进为 Blinn-Phong 模型

参考资源