Skip to content

💡 Shader 进阶 - 光照与材质系统的物理基础 ​

1. 🌟 为什么需要光照? ​

1.1 从2D到3D的关键突破 ​

在之前的文章中,我们主要处理二维图案和纹理。但要创造真实的三维世界,必须理解光如何与物体表面相互作用。

💡 核心问题:如果没有光照,所有3D物体看起来都是平的!因为我们看到的"立体感"本质上就是光照产生的明暗变化。

1.2 观察现实世界的材质差异 ​

仔细观察身边的物体,你会发现它们对光的反应完全不同:

  • 🏀 篮球:橡胶表面粗糙,光均匀散射到各个方向(漫反射为主)
  • 🔮 玻璃球:表面光滑,有清晰的镜面高光和折射(镜面反射和透射)
  • 🪨 岩石:粗糙、凹凸不平,光照分布不均匀(复杂漫反射)
  • 🥇 金属:几乎不吸收光,强烈反射周围环境(强镜面反射)

💡 深层理解:这些视觉差异的本质是物体表面的微观结构决定了光的反射方式。光照模型就是用数学公式来模拟这些物理现象!

1.3 光照模型的作用 ​

光照模型回答了一个核心问题:给定一个表面点,它应该显示什么颜色?

这个颜色取决于:

  1. 光源:光从哪里来?有多亮?什么颜色?
  2. 材质:表面是什么材质?如何反射光?
  3. 视角:观察者从哪个角度看?

掌握光照模型,你就能在GPU上模拟现实世界的任何材质!


2. 📐 光照基础 - 从物理到数学 ​

2.1 光照三要素:三个关键向量 ​

任何光照计算都基于三个向量,它们描述了光、表面和观察者的几何关系:

         N (法线)
          ↑
          │
          │
      ────●──── 表面
         ╱ ╲
    L ╱     ╲ V
     ╱       ╲
    ╱         ╲
  光源        观察者

N = Normal (法线方向)
L = Light Direction (指向光源)
V = View Direction (指向摄像机)

💡 类比理解:想象你站在阳光下:

  • 法线N:你头顶朝天的方向(表面朝向哪里)
  • 光线L:从你指向太阳的方向(光从哪里来)
  • 视线V:从你指向观察者的方向(别人从哪里看你)

2.2 核心概念详解 ​

2.2.1 法线 (Normal) - 表面的"朝向" ​

定义与作用:

  • 定义:垂直于表面的方向向量
  • 作用:决定表面朝向哪里,是所有光照计算的基础
  • 性质:必须是单位向量(长度为1),通常用 normalize() 归一化
glsl
vec3 N = normalize(v_normal);  // 确保法线长度为1

💡 为什么需要归一化?
点积公式:dot(A, B) = |A| × |B| × cos(θ)
如果A和B都是单位向量(长度=1),结果直接等于 cos(θ),便于计算光照强度。
如果不归一化,长度不是1,点积结果就不等于余弦值,导致光照计算错误。

⚠️ 常见错误与排查:

  • 忘记归一化法线:光照忽明忽暗,看起来很奇怪
  • 法线方向反了:物体背面亮,正面暗(检查法线是否为负)
  • 法线插值后未归一化:顶点着色器传递到片元着色器的法线经过插值,长度可能不是1,必须重新归一化

2.2.2 光照方向 (Light Direction) - 光从哪里来 ​

定义与计算:

  • 定义:从表面点指向光源的向量
  • 计算:L = normalize(光源位置 - 表面位置)
  • 注意:有些教程定义为"光线传播方向"(从光源指向表面),符号相反!
glsl
vec3 L = normalize(u_lightPos - v_position);  // 从表面指向光源

💡 向量方向的约定:
在这个系列教程中,我们统一使用"从表面发出"的约定:

  • L:从表面点指向光源(光来的方向)
  • V:从表面点指向观察者(观察的方向)
  • R:从表面点指向反射方向(光反射的方向)

这样所有向量都以表面点为"起点",便于理解和计算。

⚠️ 教程间的差异: 有些教程(如OpenGL红宝书)定义L为"光线传播方向"(从光源到表面),这时公式会变成:

glsl
vec3 L = normalize(v_position - u_lightPos);  // 注意顺序相反!

两种约定都可以,但要在整个项目中保持一致!

2.2.3 视线方向 (View Direction) - 观察者在哪里 ​

定义与用途:

  • 定义:从表面点指向摄像机/观察者的向量
  • 计算:V = normalize(摄像机位置 - 表面位置)
  • 用途:计算镜面高光(视角越正对表面,高光越明显)
glsl
vec3 V = normalize(u_viewPos - v_position);  // 从表面指向观察者

💡 视线方向的重要性:

  • 漫反射:不需要V,因为漫反射与观察角度无关
  • 镜面反射:必须有V,因为高光只在特定视角可见
  • 菲涅尔效果:需要V计算视角与法线的夹角(例如水面边缘更反光)

💡 为什么都是"指向"某处?
统一约定所有向量都从表面点"发出",这样点积的物理意义更清晰:

  • dot(N, L) > 0:表面朝向光源(能接收到光)
  • dot(N, L) = 0:表面与光线垂直(掠射)
  • dot(N, L) < 0:表面背对光源(完全接收不到光)

这比混合使用"指向"和"来自"的方向更容易理解和调试。

2.3 光的两种反射:漫反射与镜面反射 ​

💡 前置知识:光打到表面后,会发生两种不同的反射,它们混合在一起形成我们看到的最终颜色。

类型物理现象视觉效果材质示例
漫反射 (Diffuse)光均匀散射到各个方向柔和、无光泽、"哑光"纸张、布料、粗糙石头
镜面反射 (Specular)光沿特定方向反射高光、光泽、"亮斑"金属、塑料、水面
漫反射:                镜面反射:
   ↗  ↑  ↖              ↑
  ↗   ↑   ↖             │ R (反射光)
 ●─────────●            ●
  ↘   │   ↙            ╱
   ↘  ↓  ↙            ╱ L (入射光)
                      ╱
光向所有方向散射      光沿镜面反射方向

2.3.1 漫反射 (Diffuse) 的物理本质 ​

微观原理:粗糙表面有无数微小凹凸,光线打上去后在不同方向反射,最终混合成均匀散射。

💡 类比理解1 - 墙面泼水:
想象往粗糙的墙上泼水,水花会向四面八方溅开,没有固定方向。光在粗糙表面也是这样,所以无论从哪个角度看,亮度都差不多(只和光源角度有关)。

💡 类比理解2 - 磨砂玻璃:
透明玻璃可以看到清晰的反射(镜面反射),但磨砂玻璃表面粗糙,光线被打散,看不到清晰反射,只能看到均匀的亮度(漫反射)。

关键特性(重要!):

  • ✅ 亮度只取决于光照角度(光垂直照射最亮,斜射变暗)
  • ✅ 与观察角度无关(从任何方向看都一样亮)
  • ✅ 提供物体的"固有色"(物体本身的颜色)
  • ✅ 让物体看起来"哑光"、"无光泽"

📖 物理解释 - 为什么与观察角度无关?
粗糙表面的微小凹凸把光线打散到所有方向,每个方向的反射光强度都相同。所以无论你从哪里看(视线V的方向),看到的亮度都一样,只取决于"有多少光打到表面上"(即N·L)。

2.3.2 镜面反射 (Specular) 的物理本质 ​

微观原理:光滑表面上的光线按照"入射角=反射角"的规律反射,只在特定角度能看到反射光。

💡 类比理解1 - 照镜子:
照镜子时,你只能在特定角度看到反射。稍微偏一点,就看不到了。镜面高光也是这样,只有视线接近反射方向时才能看到亮斑。

💡 类比理解2 - 台球反弹:
想象台球撞到桌边,它会按照"入射角=反射角"弹回。光线在光滑表面也遵循这个规律,只有站在"反弹方向"的观察者才能看到反射光。

关键特性(重要!):

  • ✅ 亮度取决于视线和反射方向的夹角(视线越接近反射方向,越亮)
  • ✅ 与观察角度强相关(移动视角,高光位置会变化)
  • ✅ 形成明显的高光点(局部很亮的"亮斑")
  • ✅ 高光颜色通常接近光源颜色(而非物体颜色)

📖 物理解释 - 为什么与观察角度相关?
光滑表面的反射是"定向"的,只在特定方向(反射方向R)有强反射光。观察者的视线V越接近R,看到的反射光越强。偏离R一点点,反射光就快速衰减。

💡 为什么高光是光源颜色?

  • 漫反射:光进入物体表面,被吸收后再发出,带上了物体的颜色(红苹果吸收蓝绿光,只发出红光)
  • 镜面反射:光直接在表面反射,没有进入物体内部,保持光源的颜色(白光照射,高光也是白色)
  • 例外:金属的高光会带有金属颜色,因为金属的自由电子会吸收和重新辐射特定波长的光

2.3.3 实际材质的混合比例 ​

现实中的材质都是漫反射和镜面反射的混合,比例不同决定了材质的视觉特性:

材质漫反射占比镜面反射占比视觉特征
纸张/布料~100%~0%完全哑光,无高光
粗糙塑料~90%~10%微弱高光,主要是固有色
光滑塑料~70%~30%明显高光,有光泽感
陶瓷/瓷器~50%~50%高光和固有色同样重要
抛光金属~5%~95%强烈高光,固有色很弱
镜子~0%~100%纯镜面反射,看不到固有色

💡 如何判断材质类型?
观察一个物体时:

  • 看到柔和的明暗变化,没有明显亮点 → 漫反射为主(纸张、布料)
  • 看到明显的亮斑,而且移动视角时亮斑位置变化 → 镜面反射为主(金属、塑料)
  • 既有明暗变化又有高光 → 两者混合(大多数材质)

3. 🔆 经典光照模型 ​

3.1 Lambert 漫反射模型(最简单的光照) ​

💡 为什么从Lambert开始?
Lambert是最基础的光照模型,只需一行代码就能让3D物体有立体感!
理解它是学习所有光照模型的基础,而且它仍然被广泛应用于现代渲染中。

3.1.1 物理原理:余弦定律 ​

核心思想:表面亮度与入射角度的余弦值成正比。

强度 = max(N · L, 0)

公式拆解:
- N · L:点积运算
- 数学上:dot(N, L) = |N| × |L| × cos(θ)
- 因为N和L都是单位向量(长度=1),所以结果就是 cos(θ)
- θ:法线N和光线L之间的夹角
- max(..., 0):确保背光面不受照(负值截断为0)

💡 为什么是余弦?物理解释

想象太阳光照在地面上,光线是平行的:

情况1 - 中午(光垂直照射):

  • 1平方米的光均匀分布在1平方米的地面上
  • 接收到的光能 = 100%
  • cos(0°) = 1.0 → 亮度 = 100%

情况2 - 傍晚(光斜射60°):

  • 1平方米的光分散到2平方米的地面上(因为斜着照)
  • 每平方米接收到的光能 = 50%
  • cos(60°) = 0.5 → 亮度 = 50%

情况3 - 夜晚(光从背面来):

  • 地面背对太阳,完全照不到
  • cos(120°) = -0.5 → 截断为0 → 亮度 = 0%

📖 数学与物理的统一:
余弦定律不是凭空想出来的,而是真实的物理规律:

  • 光是能量流,遵循能量守恒
  • 斜射时,相同的光束覆盖更大面积,单位面积的能量密度降低
  • 降低的比例正好是余弦值:cos(θ) = 垂直面积 / 实际面积

3.1.2 图解不同入射角的效果 ​

光垂直照射 (θ=0°):     光斜射 (θ=60°):      光从背面来 (θ=120°):
    L                    L                     ╲ L
    ↓                   ╱                       ╲
    ●──N               ●──N                     ●──N
                      ╱                         ╱
cos(0°) = 1.0       cos(60°) = 0.5          cos(120°) = -0.5 → 0.0
最亮(100%)        中等亮度(50%)          不亮(背光面)

💡 点积的几何意义与判断:

点积值夹角θ物理意义视觉效果
dot(N, L) = 1.00°光垂直照射(最亮)正面正对光源
dot(N, L) = 0.745°光斜射中等亮度
dot(N, L) = 0.090°光掠射(几乎不亮)侧面边缘
dot(N, L) = -0.5120°光从背面来(截断为0)背光面(阴影)

判断规则:

  • dot(N, L) > 0:光从正面照来,能看见光(越大越亮)
  • dot(N, L) = 0:光平行于表面(掠射),几乎看不见
  • dot(N, L) < 0:光从背面照来,完全看不见(必须截断为0,否则出错)

⚠️ 为什么必须截断负值?
如果不用 max(..., 0.0) 截断,背光面会得到负的亮度值,导致:

  • 颜色计算出错(负数没有物理意义)
  • 背光面可能显示为错误的颜色
  • 最终图像看起来很奇怪(黑色变成异常的颜色)

3.1.3 🔧 GLSL 实现 ​

💡 片元着色器(Fragment Shader)的作用:
这个Shader在每个像素上独立运行,计算该像素应该显示的颜色。
对于一个512x512的模型,这段代码会执行 262,144 次(每个像素一次)!

glsl
#ifdef GL_ES
precision mediump float;  // 中等精度浮点数(移动端性能考虑)
#endif

// ========== Uniform变量(整个模型共享的常量) ==========
uniform vec3 u_lightPos;    // 光源位置(世界空间坐标)
uniform vec3 u_lightColor;  // 光源颜色(例如:白光 = vec3(1.0))
uniform vec3 u_objectColor; // 物体本身颜色(例如:红色 = vec3(1.0, 0.0, 0.0))

// ========== Varying变量(从顶点着色器插值传来) ==========
varying vec3 v_normal;      // 当前像素的法线(世界空间)
varying vec3 v_position;    // 当前像素的世界坐标

void main() {
    // ========== 步骤1:准备向量 ==========
    // 归一化法线(插值可能改变长度)
    vec3 N = normalize(v_normal);
    
    // 计算光照方向:从当前像素指向光源
    vec3 L = normalize(u_lightPos - v_position);
    
    // ========== 步骤2:计算Lambert漫反射 ==========
    // dot(N, L):计算法线和光线的夹角余弦
    // max(..., 0.0):背光面截断为0(不能是负数)
    float diff = max(dot(N, L), 0.0);
    
    // ========== 步骤3:合成最终颜色 ==========
    // 最终颜色 = 物体固有色 × 光的颜色 × 漫反射强度
    // 这样可以模拟彩色光照(例如红色光照在白色物体上显示为红色)
    vec3 diffuse = u_objectColor * u_lightColor * diff;
    
    // ========== 步骤4:输出颜色 ==========
    gl_FragColor = vec4(diffuse, 1.0);  // Alpha通道设为1.0(不透明)
}

💡 为什么要乘以 u_objectColor 和 u_lightColor?

  • u_objectColor:物体固有色(例如红色苹果)
  • u_lightColor:光的颜色(例如黄色灯光)
  • 相乘模拟"光与物体表面相互作用"的物理过程
  • 例子:白光(1,1,1) × 红色(1,0,0) = 红色(1,0,0)
  • 例子:蓝光(0,0,1) × 红色(1,0,0) = 黑色(0,0,0)(蓝光照在红色物体上看不见!)

⚠️ 常见问题:

  • 全黑:检查法线是否正确,是否归一化
  • 忽明忽暗:确认 v_normal 和 v_position 在世界空间(不是局部空间)
  • 背面也亮:忘记用 max(..., 0.0) 截断

3.1.4 顶点着色器:传递数据给片元着色器 ​

💡 顶点着色器的职责:
顶点着色器在每个顶点上运行(而片元着色器在每个像素上运行),它的任务是:

  1. 空间变换:把顶点位置从模型空间转换到世界空间、视图空间、裁剪空间
  2. 法线变换:把法线转换到世界空间(注意:法线的变换和位置不同!)
  3. 数据传递:把世界坐标、法线等数据传递给片元着色器(通过varying变量自动插值)

为什么需要顶点着色器?
GPU渲染管线分为两个阶段:

  • 顶点阶段:处理顶点(数量少,例如1000个顶点)
  • 片元阶段:处理像素(数量多,例如100万个像素)

把能在顶点阶段完成的计算(如空间变换)提前做好,可以大幅减少片元阶段的计算量,提高性能!

glsl
// ========== Attribute变量(每个顶点不同) ==========
attribute vec3 position;  // 顶点位置(模型空间)
attribute vec3 normal;    // 顶点法线(模型空间)

// ========== Uniform变量(整个模型共享) ==========
uniform mat4 modelMatrix;       // 模型矩阵:模型空间 → 世界空间
uniform mat4 viewMatrix;        // 视图矩阵:世界空间 → 视图空间
uniform mat4 projectionMatrix;  // 投影矩阵:视图空间 → 裁剪空间
uniform mat3 normalMatrix;      // 法线矩阵(下面解释为什么需要)

// ========== Varying变量(输出到片元着色器) ==========
varying vec3 v_normal;     // 世界空间的法线
varying vec3 v_position;   // 世界空间的位置

void main() {
    // ========== 步骤1:转换位置到世界空间 ==========
    vec4 worldPos = modelMatrix * vec4(position, 1.0);
    v_position = worldPos.xyz;  // 传递给片元着色器
    
    // ========== 步骤2:转换法线到世界空间 ==========
    // ⚠️ 法线不能直接用modelMatrix变换!原因见下方提示
    v_normal = normalMatrix * normal;
    
    // ========== 步骤3:投影到屏幕空间 ==========
    // 完整变换链:模型空间 → 世界空间 → 视图空间 → 裁剪空间
    gl_Position = projectionMatrix * viewMatrix * worldPos;
}

⚠️ 为什么法线需要特殊的 normalMatrix?(重要概念!)

问题:如果用 modelMatrix 直接变换法线,在物体被非均匀缩放时会出错!

例子 - 正方体被拉伸:
想象一个正方体被沿X轴拉伸2倍(变成长方体):

  • 顶点坐标:(1, 1, 1) → (2, 1, 1) ✅ 正确(拉伸后的新位置)
  • 法线:(1, 0, 0) → (2, 0, 0) ❌ 错误!法线长度变了,且方向可能歪斜

为什么错误?

  1. 法线长度变了(从1变成2),不再是单位向量
  2. 更严重的是:在某些情况下法线的方向会歪斜,不再垂直于表面!

正确做法:用 normalMatrix = transpose(inverse(mat3(modelMatrix)))
这个矩阵能正确处理非均匀缩放和旋转,保证法线永远垂直于表面。

数学原理(可选阅读):

  • 顶点坐标变换:p' = M × p(用modelMatrix)
  • 法线变换:n' = (M⁻¹)ᵀ × n(用modelMatrix的逆转置)
  • 为什么不同?因为法线是"余切向量"(cotangent vector),变换规则和普通向量不同

JavaScript计算normalMatrix:

javascript
// Three.js会自动计算,无需手动处理:
mesh.material.uniforms.normalMatrix.value = mesh.normalMatrix;

// 如果手动计算:
const normalMatrix = new THREE.Matrix3()
    .getNormalMatrix(modelMatrix);  // 内部实现了transpose(inverse(...))

💡 空间变换流程详解:

模型空间        世界空间         视图空间        裁剪空间        屏幕空间
(局部坐标)   →  (全局坐标)   →  (相机视角)   →  (规范化)   →  (像素坐标)
   ↓              ↓               ↓              ↓              ↓
建模时的坐标    场景中的位置    从相机看的位置  GPU裁剪处理    最终像素

 modelMatrix    viewMatrix    projectionMatrix
     ↓             ↓               ↓
 position变换   摄像机变换      投影变换
     ↓
 normal变换(用normalMatrix,不是modelMatrix!)

✨ 光照计算发生在「世界空间」!

为什么光照在世界空间计算?

  • 光源位置通常定义在世界空间
  • 摄像机位置也在世界空间
  • 在世界空间计算,所有物体使用统一的坐标系,光照结果才正确

3.2 Phong 光照模型(经典三分量光照) ​

💡 为什么需要Phong?
Lambert只有漫反射,无法模拟高光(镜面反射),看起来太"哑光"。Phong模型添加了环境光和镜面反射,让物体更真实!

Phong模型的三个组成部分 ​

核心公式:

最终颜色 = 环境光 (Ambient) + 漫反射 (Diffuse) + 镜面反射 (Specular)

💡 类比理解:想象一个塑料球:

  • 环境光:即使不在直射光下,也能隐约看见轮廓(来自周围环境的反射)
  • 漫反射:球体受光面和背光面的明暗变化(Lambert那部分)
  • 镜面反射:球面上的一个亮斑(高光)

3.2.1 🌍 环境光 (Ambient) - 全局基础亮度 ​

物理意义:模拟间接光照(天空光、墙面反射、其他物体反射等)

glsl
vec3 ambient = u_ambientStrength * u_lightColor * u_objectColor;

// 参数说明:
// u_ambientStrength: 环境光强度(通常0.1-0.3,控制"最低亮度")
// u_lightColor: 光源颜色
// u_objectColor: 物体颜色

💡 为什么需要环境光?现实对比

想象一个房间里的红色苹果:

  • 只有直射光(Lambert):正对光源的一面亮,背光面全黑,看不见任何细节
  • 加上环境光:背光面虽然暗,但仍然能看见苹果的轮廓和细节

现实中很少有完全黑暗的阴影,因为总有间接光:

  • 天空散射的蓝光
  • 墙面反射的光
  • 其他物体反射的光

环境光就是用一个常量简单模拟这些复杂的间接光照。

💡 环境光的计算特点:

  • 与位置无关:每个像素的环境光强度都一样
  • 与角度无关:不考虑法线方向
  • 非常快:只需一次乘法
  • 不够真实:真实的间接光会有方向性和颜色变化(需要更复杂的全局光照技术,如GI)

⚠️ 环境光强度参考值:

强度值视觉效果适用场景
0.05-0.1微弱环境光,阴影很暗戏剧性光照、恐怖游戏、夜晚场景
0.15-0.25中等环境光,平衡大多数游戏和3D应用的默认值
0.3-0.5强环境光,阴影浅卡通渲染、明亮的室外场景
> 0.6过强,失去立体感一般不推荐

3.2.2 ☀️ 漫反射 (Diffuse) - Lambert模型 ​

这部分和3.1节的Lambert完全相同(Phong模型复用了Lambert的漫反射):

glsl
float diff = max(dot(N, L), 0.0);
vec3 diffuse = diff * u_lightColor * u_objectColor;

💡 Phong模型的分层结构:

  • 环境光(Ambient):提供最低亮度,简单粗暴
  • 漫反射(Diffuse):提供主要的明暗变化(Lambert),是物体颜色的主要来源
  • 镜面反射(Specular):提供高光,是"光泽感"的来源

这三个分量相加得到最终颜色:Final = Ambient + Diffuse + Specular

3.2.3 ✨ 镜面反射 (Specular) - 高光计算 ​

原理:当反射方向R接近视线方向V时,观察者能看到镜面反射的高光。

      N (法线)
      ↑
      │
  L ╱   ╲ R (反射方向)
   ╱     ╲
  ╱       ╲ V (视线)
 ╱         ↘
光源        观察者

镜面强度 = (R · V)^shininess

R和V越接近 → 点积越大 → 高光越亮

反射向量的计算:

光线遵循"入射角 = 反射角"定律,反射向量公式:

R = 2(N · L)N - L

推导过程(可选):
1. 入射光L在法线N上的投影:proj = (N · L)N
2. 反射光 = L + 2×(投影 - L分量在法线的部分)
3. 化简得:R = 2(N · L)N - L

💡 GLSL简化:不用手动计算,用内置函数:

glsl
vec3 R = reflect(-L, N);  // 注意:L要取反,因为reflect函数的参数是"入射方向"

shininess参数:

幂次 shininess 控制高光的"聚集程度":

glsl
float spec = pow(max(dot(R, V), 0.0), u_shininess);
  • shininess = 2:高光范围很大,模糊(粗糙塑料)
  • shininess = 32:高光范围中等(光滑塑料)
  • shininess = 128:高光范围很小,锐利(抛光金属)

💡 为什么用幂次?
幂次函数让高光"快速衰减"。例如:

  • dot(R, V) = 0.9 → 0.9^2 = 0.81 → 0.9^32 = 0.04 → 0.9^128 ≈ 0
  • 只有R和V非常接近时(如0.99),高光才明显

🔧 完整 Phong 实现 ​

glsl
uniform vec3 u_lightPos;         // 光源位置
uniform vec3 u_viewPos;          // 摄像机位置
uniform vec3 u_lightColor;       // 光源颜色
uniform vec3 u_objectColor;      // 物体颜色

uniform float u_ambientStrength; // 环境光强度(推荐0.1-0.2)
uniform float u_shininess;       // 高光锐度(推荐8-128)

varying vec3 v_normal;
varying vec3 v_position;

void main() {
    // ========== 步骤1:准备向量 ==========
    vec3 N = normalize(v_normal);                 // 法线
    vec3 L = normalize(u_lightPos - v_position);  // 光照方向
    vec3 V = normalize(u_viewPos - v_position);   // 视线方向
    
    // ========== 步骤2:计算三个光照分量 ==========
    
    // 🌍 环境光(全局基础亮度)
    vec3 ambient = u_ambientStrength * u_lightColor;
    
    // ☀️ 漫反射(Lambert余弦定律)
    float diff = max(dot(N, L), 0.0);
    vec3 diffuse = diff * u_lightColor;
    
    // ✨ 镜面反射(Phong高光)
    vec3 R = reflect(-L, N);  // 计算反射方向(-L是入射方向)
    float spec = pow(max(dot(R, V), 0.0), u_shininess);
    vec3 specular = spec * u_lightColor;
    
    // ========== 步骤3:合成最终颜色 ==========
    // 注意:镜面反射通常不乘以objectColor(高光是光源颜色)
    vec3 result = (ambient + diffuse) * u_objectColor + specular;
    
    gl_FragColor = vec4(result, 1.0);
}

💡 为什么镜面反射不乘以物体颜色?

  • 漫反射:光被物体表面吸收后再发出,携带物体颜色
  • 镜面反射:光直接反射,不与物体材质作用,保持光源颜色
  • 例外:金属的高光会带有金属本身的颜色(PBR会处理这个)

参数效果对比:

shininess高光效果视觉描述材质示例
2⭕ 大而模糊高光占据大片区域粗糙塑料、橡胶
32🔘 中等大小明显但不刺眼光滑塑料、陶瓷
128⚪ 小而锐利亮点集中,类似镜面抛光金属、玻璃
256+✨ 极小极亮几乎是点状高光钻石、镜子

💡 调参技巧:

  • 先设 u_ambientStrength = 0.2,u_shininess = 32
  • 观察效果,根据需要调整
  • 如果高光看不见,检查摄像机位置和光源位置是否合理

3.3 Blinn-Phong 模型(性能优化版) ​

💡 为什么需要改进Phong?
Phong模型在某些掠射角度(光线几乎平行于表面)会产生高光突然消失的不自然现象。Blinn改进了这个问题!

Phong的问题 ​

当视角V和反射方向R的夹角 > 90°时,dot(R, V) 变负,高光突然消失。这在掠射角度很常见,导致不自然的高光"闪烁"。

Blinn的改进:半程向量 ​

核心思想:用半程向量H代替反射向量R。

      N (法线)
      ↑ H (半程向量)
      │╱
      ╱
  L ╱   ╲ V
   ╱     ╲
  ╱       ╲

H = normalize(L + V)  ← L和V的平分线方向

镜面强度 = (N · H)^shininess

💡 为什么用半程向量?
想象你用镜子反射太阳光到眼睛里:

  • Phong思路:先算光的反射方向R,再看R和视线V是否接近
  • Blinn思路:直接看法线N是否对准L和V的"中间方向"H

两者物理意义等价,但Blinn在数学上更稳定!

优势对比 ​

特性PhongBlinn-Phong
计算量需要 reflect()只需一次向量加法 ✅
稳定性掠射角会闪烁过渡平滑 ✅
物理准确性严格镜面反射近似但更实用
业界采用较少广泛使用 ✅

💡 Three.js的选择:MeshPhongMaterial 实际使用的是Blinn-Phong,而非Phong!

🔧 实现 ​

glsl
void main() {
    // ========== 步骤1:准备向量 ==========
    vec3 N = normalize(v_normal);
    vec3 L = normalize(u_lightPos - v_position);
    vec3 V = normalize(u_viewPos - v_position);
    
    // 🌟 计算半程向量(L和V的平分线)
    vec3 H = normalize(L + V);
    // 注意:一定要归一化!(L+V)的长度不是1
    
    // ========== 步骤2:环境光 + 漫反射(与Phong相同) ==========
    vec3 ambient = u_ambientStrength * u_lightColor;
    float diff = max(dot(N, L), 0.0);
    vec3 diffuse = diff * u_lightColor;
    
    // ========== 步骤3:Blinn-Phong镜面反射 ==========
    // 用N·H代替R·V
    float spec = pow(max(dot(N, H), 0.0), u_shininess);
    vec3 specular = spec * u_lightColor;
    
    // ========== 步骤4:合成最终颜色 ==========
    vec3 result = (ambient + diffuse) * u_objectColor + specular;
    
    gl_FragColor = vec4(result, 1.0);
}

💡 Phong vs Blinn-Phong代码对比:

Phong:

glsl
vec3 R = reflect(-L, N);         // 需要计算反射向量
float spec = pow(max(dot(R, V), 0.0), shininess);

Blinn-Phong:

glsl
vec3 H = normalize(L + V);       // 只需向量加法
float spec = pow(max(dot(N, H), 0.0), shininess);

更简单!

⚠️ shininess的调整:
Blinn-Phong的高光比Phong更"锐利",可能需要调高shininess值以获得相同视觉效果:

  • Phong的shininess=32 ≈ Blinn-Phong的shininess=64
  • 实际使用时建议在32-256范围内调整

4. 🗺️ 法线贴图 (Normal Mapping) - 细节的魔法 ​

4.1 为什么需要法线贴图? ​

问题:多边形的限制 ​

困境:3D模型的面数有限,无法表现所有细微细节。

低模(1000面):         法线贴图后:
  ╱╲╱╲╱╲               ∿∾∿∾∿∾
 ╱  ╲  ╱              ∾∿∾∿∾∿∾
╱────╲╱               ∿∾∿∾∿∾∿

平滑但缺细节           看起来凹凸不平!
                      (实际几何没变)

💡 类比理解:
想象用积木搭建一座雕像:

  • 高模:用1000万个小积木 → 细节丰富,但电脑跑不动
  • 低模:用1000个大积木 → 流畅,但太方块化
  • 法线贴图:用1000个大积木,但画上细节 → 兼顾性能和视觉!

法线贴图的解决方案 ​

核心思想:用贴图存储每个像素的法线方向,而不是真正改变几何形状。

效果:

  • ✅ 几何保持简单(面数少,性能好)
  • ✅ 光照计算时用贴图的法线(看起来有细节)
  • ✅ 节省90%以上的多边形数量

⚠️ 法线贴图的局限:

  • 只改变光照,不改变轮廓(从侧面看仍然是平的)
  • 不能产生自遮挡(凹坑内部不会产生阴影)
  • 需要配合视差贴图才能模拟深度感

4.2 切线空间 (Tangent Space) - 法线贴图的坐标系 ​

💡 为什么需要切线空间?
法线贴图存储的是相对于表面的法线,而光照计算在世界空间进行。我们需要一个桥梁把两者连接起来!

切线空间的三个基向量 ​

法线贴图需要一个局部坐标系,由三个互相垂直的向量组成:

        N (法线)
        ↑
        │
    ────●──── 表面
       ╱│
      ╱ │
     T  B
     
T = Tangent    (切线,沿UV的U方向)
B = Bitangent  (副切线,沿UV的V方向)
N = Normal     (法线,垂直于表面)

💡 类比理解:
想象一张贴在曲面上的邮票:

  • T:邮票的"横向"(左右方向)
  • B:邮票的"纵向"(上下方向)
  • N:邮票朝外的"法线"(垂直于邮票)

这三个方向形成一个"局部坐标系",让我们知道法线贴图的"上下左右"对应世界的哪个方向。

TBN矩阵:切线空间 ↔ 世界空间 ​

TBN矩阵是一个3x3矩阵,用于空间变换:

世界法线 = TBN × 切线空间法线

其中 TBN = [T | B | N](三个列向量组成的矩阵)

💡 数学本质:
TBN矩阵实际上是旋转矩阵,把法线从"贴图的局部坐标系"转到"世界坐标系"。

⚠️ T、B、N必须满足:

  • 三者互相垂直(正交)
  • 都是单位向量(长度=1)
  • 满足右手定则:B = cross(N, T)

4.3 实现步骤 ​

4.3.1 步骤1: 顶点着色器计算 TBN ​

glsl
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
attribute vec3 tangent;  // 切线 (需要预计算或生成)

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

varying vec2 v_uv;
varying vec3 v_position;
varying mat3 v_TBN;  // TBN矩阵

void main() {
    vec4 worldPos = modelMatrix * vec4(position, 1.0);
    v_position = worldPos.xyz;
    v_uv = uv;
    
    // 🧭 构建 TBN 矩阵
    vec3 T = normalize(normalMatrix * tangent);
    vec3 N = normalize(normalMatrix * normal);
    
    // Gram-Schmidt 正交化 (确保T垂直于N)
    T = normalize(T - dot(T, N) * N);
    
    // 计算副切线
    vec3 B = cross(N, T);
    
    // 组装 TBN 矩阵
    v_TBN = mat3(T, B, N);
    
    gl_Position = projectionMatrix * viewMatrix * worldPos;
}

4.3.2 步骤2: 片元着色器采样法线贴图 ​

glsl
uniform sampler2D u_normalMap;
uniform sampler2D u_diffuseMap;
uniform vec3 u_lightPos;
uniform vec3 u_viewPos;

varying vec2 v_uv;
varying vec3 v_position;
varying mat3 v_TBN;

void main() {
    // 🗺️ 从法线贴图采样
    vec3 tangentNormal = texture2D(u_normalMap, v_uv).rgb;
    
    // 🔄 从 [0,1] 映射到 [-1,1]
    tangentNormal = tangentNormal * 2.0 - 1.0;
    
    // 🌍 转换到世界空间
    vec3 N = normalize(v_TBN * tangentNormal);
    
    // 其余部分同 Blinn-Phong
    vec3 L = normalize(u_lightPos - v_position);
    vec3 V = normalize(u_viewPos - v_position);
    vec3 H = normalize(L + V);
    
    float diff = max(dot(N, L), 0.0);
    float spec = pow(max(dot(N, H), 0.0), 32.0);
    
    // 🎨 基础颜色从 diffuse 贴图采样
    vec3 baseColor = texture2D(u_diffuseMap, v_uv).rgb;
    
    vec3 ambient = 0.1 * baseColor;
    vec3 diffuse = diff * baseColor;
    vec3 specular = spec * vec3(1.0);
    
    vec3 color = ambient + diffuse + specular;
    
    gl_FragColor = vec4(color, 1.0);
}

4.4 法线贴图的颜色编码 ​

法线贴图看起来是蓝紫色的:

颜色含义:
- R通道 (红): X轴分量 (水平)
- G通道 (绿): Y轴分量 (垂直)
- B通道 (蓝): Z轴分量 (朝向观察者)

平坦表面: RGB(128, 128, 255) 
         = 法线(0, 0, 1) 
         = 垂直朝上

4.5 强度控制 ​

调整法线贴图的影响强度:

glsl
// 采样法线
vec3 tangentNormal = texture2D(u_normalMap, v_uv).rgb;
tangentNormal = tangentNormal * 2.0 - 1.0;

// 🎛️ 控制强度 (0 = 完全平滑, 1 = 完整效果)
float normalStrength = 0.5;
tangentNormal.xy *= normalStrength;

// 重新归一化
tangentNormal = normalize(tangentNormal);

vec3 N = normalize(v_TBN * tangentNormal);

5. 🏔️ 视差贴图 (Parallax Mapping) - 假的凹凸 ​

5.1 原理 ​

法线贴图只改变光照,不改变几何。视差贴图进一步伪造深度感:

侧视图:

    观察者
      │V
      │  ╲
      │   ╲  实际采样点
      │    ●───────┐
      │   ╱        │ 偏移
  ────●───────────┘
   原始UV      高度贴图

根据视角和高度,偏移UV坐标

5.2 基础视差贴图 ​

glsl
uniform sampler2D u_diffuseMap;
uniform sampler2D u_heightMap;  // 高度图 (灰度)
uniform float u_heightScale;    // 高度缩放 (0.01 ~ 0.1)

varying vec2 v_uv;
varying vec3 v_viewDirTangent;  // 视线方向 (切线空间)

void main() {
    // 🗺️ 采样高度
    float height = texture2D(u_heightMap, v_uv).r;
    
    // 📐 计算UV偏移
    vec2 offset = v_viewDirTangent.xy * (height * u_heightScale);
    
    // 🎯 偏移后的UV
    vec2 parallaxUV = v_uv - offset;
    
    // 🎨 用偏移后的UV采样其他贴图
    vec3 color = texture2D(u_diffuseMap, parallaxUV).rgb;
    
    gl_FragColor = vec4(color, 1.0);
}

顶点着色器补充:

glsl
varying vec3 v_viewDirTangent;

void main() {
    // ... (之前的代码)
    
    // 🌍 世界空间的视线方向
    vec3 viewDir = v_position - u_viewPos;
    
    // 🔄 转到切线空间
    v_viewDirTangent = viewDir * v_TBN;  // 注意:乘以TBN的转置
}

5.3 陡峭视差贴图 (Steep Parallax) ​

基础视差在陡峭角度会失真,改进版使用分层采样:

glsl
uniform sampler2D u_heightMap;
uniform float u_heightScale;

const float minLayers = 8.0;
const float maxLayers = 32.0;

vec2 parallaxMapping(vec2 uv, vec3 viewDir) {
    // 📊 根据视角动态调整层数
    float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));
    
    // 📏 每层的深度步长
    float layerDepth = 1.0 / numLayers;
    float currentLayerDepth = 0.0;
    
    // 📐 每层的UV偏移
    vec2 deltaUV = viewDir.xy * u_heightScale / numLayers;
    
    vec2 currentUV = uv;
    float currentHeight = texture2D(u_heightMap, currentUV).r;
    
    // 🔄 逐层下降,直到找到交点
    while(currentLayerDepth < currentHeight) {
        currentUV -= deltaUV;
        currentHeight = texture2D(u_heightMap, currentUV).r;
        currentLayerDepth += layerDepth;
    }
    
    return currentUV;
}

void main() {
    vec2 parallaxUV = parallaxMapping(v_uv, normalize(v_viewDirTangent));
    
    vec3 color = texture2D(u_diffuseMap, parallaxUV).rgb;
    gl_FragColor = vec4(color, 1.0);
}

5.4 视差遮蔽贴图 (POM) ​

最高质量,添加插值优化:

glsl
vec2 parallaxOcclusionMapping(vec2 uv, vec3 viewDir) {
    // ... (分层下降,同上)
    
    // 🎯 找到交点后,进行线性插值优化
    vec2 prevUV = currentUV + deltaUV;
    
    float afterDepth = currentHeight - currentLayerDepth;
    float beforeDepth = texture2D(u_heightMap, prevUV).r - currentLayerDepth + layerDepth;
    
    float weight = afterDepth / (afterDepth - beforeDepth);
    vec2 finalUV = mix(currentUV, prevUV, weight);
    
    return finalUV;
}

6. 🌈 PBR 基础 - 基于物理的渲染 ​

6.1 什么是 PBR? ​

Physically Based Rendering (基于物理的渲染) 是现代游戏引擎的标准:

  • ✅ 基于真实物理
  • ✅ 参数直观 (粗糙度、金属度)
  • ✅ 在任何光照下都正确

6.2 核心参数 ​

参数含义范围示例
Albedo基础颜色 (不含光照)RGB木头: (0.6, 0.4, 0.2)
Metallic金属度0~1金属: 1.0, 非金属: 0.0
Roughness粗糙度0~1镜面: 0.0, 粗糙: 1.0
AO环境遮蔽0~1缝隙: 0.0, 开阔: 1.0

6.3 简化 PBR 实现 ​

glsl
uniform sampler2D u_albedoMap;
uniform sampler2D u_metallicMap;
uniform sampler2D u_roughnessMap;
uniform sampler2D u_normalMap;
uniform sampler2D u_aoMap;

uniform vec3 u_lightPositions[4];  // 多光源
uniform vec3 u_lightColors[4];
uniform vec3 u_camPos;

varying vec2 v_uv;
varying vec3 v_position;
varying mat3 v_TBN;

const float PI = 3.14159265359;

// 🎨 Fresnel方程 (Schlick近似)
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

// 📊 法线分布函数 (GGX/Trowbridge-Reitz)
float distributionGGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    
    float nom = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;
    
    return nom / denom;
}

// 📐 几何遮蔽函数
float geometrySchlickGGX(float NdotV, float roughness) {
    float r = (roughness + 1.0);
    float k = (r * r) / 8.0;
    
    float nom = NdotV;
    float denom = NdotV * (1.0 - k) + k;
    
    return nom / denom;
}

float geometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx2 = geometrySchlickGGX(NdotV, roughness);
    float ggx1 = geometrySchlickGGX(NdotL, roughness);
    
    return ggx1 * ggx2;
}

void main() {
    // 🗺️ 采样材质贴图
    vec3 albedo = texture2D(u_albedoMap, v_uv).rgb;
    float metallic = texture2D(u_metallicMap, v_uv).r;
    float roughness = texture2D(u_roughnessMap, v_uv).r;
    float ao = texture2D(u_aoMap, v_uv).r;
    
    // 🧭 法线
    vec3 tangentNormal = texture2D(u_normalMap, v_uv).rgb * 2.0 - 1.0;
    vec3 N = normalize(v_TBN * tangentNormal);
    vec3 V = normalize(u_camPos - v_position);
    
    // ✨ 计算 F0 (垂直入射时的菲涅尔反射率)
    vec3 F0 = vec3(0.04);  // 非金属默认值
    F0 = mix(F0, albedo, metallic);  // 金属使用albedo作为F0
    
    // 💡 遍历所有光源
    vec3 Lo = vec3(0.0);
    for(int i = 0; i < 4; i++) {
        vec3 L = normalize(u_lightPositions[i] - v_position);
        vec3 H = normalize(V + L);
        
        float distance = length(u_lightPositions[i] - v_position);
        float attenuation = 1.0 / (distance * distance);  // 平方衰减
        vec3 radiance = u_lightColors[i] * attenuation;
        
        // 🧮 Cook-Torrance BRDF
        float NDF = distributionGGX(N, H, roughness);
        float G = geometrySmith(N, V, L, roughness);
        vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
        
        vec3 numerator = NDF * G * F;
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
        vec3 specular = numerator / denominator;
        
        // 🎨 能量守恒: kS + kD = 1
        vec3 kS = F;  // 镜面反射比例
        vec3 kD = vec3(1.0) - kS;
        kD *= 1.0 - metallic;  // 金属没有漫反射
        
        float NdotL = max(dot(N, L), 0.0);
        Lo += (kD * albedo / PI + specular) * radiance * NdotL;
    }
    
    // 🌍 环境光 (简化版)
    vec3 ambient = vec3(0.03) * albedo * ao;
    
    vec3 color = ambient + Lo;
    
    // 🎨 HDR色调映射 (Reinhard)
    color = color / (color + vec3(1.0));
    
    // 📸 Gamma校正
    color = pow(color, vec3(1.0 / 2.2));
    
    gl_FragColor = vec4(color, 1.0);
}

7. 🎓 本章总结 ​

7.1 你学到了什么 ​

经典光照模型:

  • 🔆 Lambert 漫反射
  • 💡 Phong 三分量模型
  • ✨ Blinn-Phong 优化

高级材质技术:

  • 🗺️ 法线贴图 (Normal Mapping)
  • 🏔️ 视差贴图 (Parallax Mapping)
  • 🎯 视差遮蔽贴图 (POM)

PBR 基础:

  • 🌈 基于物理的参数系统
  • 🧮 Cook-Torrance BRDF
  • 📊 GGX法线分布、Fresnel方程、几何遮蔽

7.2 下一步 ​

下一篇 (最后一篇!) 《光线追踪与环境映射》将教你:

  1. Ray Marching: SDF的3D应用,GPU光线追踪
  2. 环境贴图: 球形、立方体环境映射
  3. 反射与折射: 真实的光学现象
  4. 全局光照: IBL (基于图像的光照)

7.3 推荐练习 ​

  1. 多光源场景

    • 实现点光源、聚光灯、平行光
    • 阴影映射基础
    • 光源衰减函数
  2. 材质库

    • 创建不同材质预设 (金属、木头、塑料)
    • 实时调整参数观察效果
    • 组合多张贴图
  3. 法线贴图生成

    • 从高度图生成法线贴图
    • Sobel算子应用
    • 强度控制
  4. PBR工作流

    • 完整的PBR材质系统
    • IBL环境光照
    • 实时GI近似

7.4 参考资源 ​

继续探索光与物质的物理奥秘吧!最后一篇见! ✨