💡 Shader 进阶 - 光照与材质系统的物理基础
1. 🌟 为什么需要光照?
1.1 从2D到3D的关键突破
在之前的文章中,我们主要处理二维图案和纹理。但要创造真实的三维世界,必须理解光如何与物体表面相互作用。
💡 核心问题:如果没有光照,所有3D物体看起来都是平的!因为我们看到的"立体感"本质上就是光照产生的明暗变化。
1.2 观察现实世界的材质差异
仔细观察身边的物体,你会发现它们对光的反应完全不同:
- 🏀 篮球:橡胶表面粗糙,光均匀散射到各个方向(漫反射为主)
- 🔮 玻璃球:表面光滑,有清晰的镜面高光和折射(镜面反射和透射)
- 🪨 岩石:粗糙、凹凸不平,光照分布不均匀(复杂漫反射)
- 🥇 金属:几乎不吸收光,强烈反射周围环境(强镜面反射)
💡 深层理解:这些视觉差异的本质是物体表面的微观结构决定了光的反射方式。光照模型就是用数学公式来模拟这些物理现象!
1.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()归一化
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(光源位置 - 表面位置) - 注意:有些教程定义为"光线传播方向"(从光源指向表面),符号相反!
vec3 L = normalize(u_lightPos - v_position); // 从表面指向光源💡 向量方向的约定:
在这个系列教程中,我们统一使用"从表面发出"的约定:
- L:从表面点指向光源(光来的方向)
- V:从表面点指向观察者(观察的方向)
- R:从表面点指向反射方向(光反射的方向)
这样所有向量都以表面点为"起点",便于理解和计算。
⚠️ 教程间的差异: 有些教程(如OpenGL红宝书)定义L为"光线传播方向"(从光源到表面),这时公式会变成:
glslvec3 L = normalize(v_position - u_lightPos); // 注意顺序相反!两种约定都可以,但要在整个项目中保持一致!
2.2.3 视线方向 (View Direction) - 观察者在哪里
定义与用途:
- 定义:从表面点指向摄像机/观察者的向量
- 计算:
V = normalize(摄像机位置 - 表面位置) - 用途:计算镜面高光(视角越正对表面,高光越明显)
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 次(每个像素一次)!
#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 顶点着色器:传递数据给片元着色器
💡 顶点着色器的职责:
顶点着色器在每个顶点上运行(而片元着色器在每个像素上运行),它的任务是:
- 空间变换:把顶点位置从模型空间转换到世界空间、视图空间、裁剪空间
- 法线变换:把法线转换到世界空间(注意:法线的变换和位置不同!)
- 数据传递:把世界坐标、法线等数据传递给片元着色器(通过varying变量自动插值)
为什么需要顶点着色器?
GPU渲染管线分为两个阶段:
- 顶点阶段:处理顶点(数量少,例如1000个顶点)
- 片元阶段:处理像素(数量多,例如100万个像素)
把能在顶点阶段完成的计算(如空间变换)提前做好,可以大幅减少片元阶段的计算量,提高性能!
// ========== 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变成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) - 全局基础亮度
物理意义:模拟间接光照(天空光、墙面反射、其他物体反射等)
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的漫反射):
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简化:不用手动计算,用内置函数:
glslvec3 R = reflect(-L, N); // 注意:L要取反,因为reflect函数的参数是"入射方向"
shininess参数:
幂次 shininess 控制高光的"聚集程度":
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 实现
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在数学上更稳定!
优势对比
| 特性 | Phong | Blinn-Phong |
|---|---|---|
| 计算量 | 需要 reflect() | 只需一次向量加法 ✅ |
| 稳定性 | 掠射角会闪烁 | 过渡平滑 ✅ |
| 物理准确性 | 严格镜面反射 | 近似但更实用 |
| 业界采用 | 较少 | 广泛使用 ✅ |
💡 Three.js的选择:
MeshPhongMaterial实际使用的是Blinn-Phong,而非Phong!
🔧 实现
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:
glslvec3 R = reflect(-L, N); // 需要计算反射向量 float spec = pow(max(dot(R, V), 0.0), shininess);Blinn-Phong:
glslvec3 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
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: 片元着色器采样法线贴图
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 强度控制
调整法线贴图的影响强度:
// 采样法线
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 基础视差贴图
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);
}顶点着色器补充:
varying vec3 v_viewDirTangent;
void main() {
// ... (之前的代码)
// 🌍 世界空间的视线方向
vec3 viewDir = v_position - u_viewPos;
// 🔄 转到切线空间
v_viewDirTangent = viewDir * v_TBN; // 注意:乘以TBN的转置
}5.3 陡峭视差贴图 (Steep Parallax)
基础视差在陡峭角度会失真,改进版使用分层采样:
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)
最高质量,添加插值优化:
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 实现
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 下一步
下一篇 (最后一篇!) 《光线追踪与环境映射》将教你:
- Ray Marching: SDF的3D应用,GPU光线追踪
- 环境贴图: 球形、立方体环境映射
- 反射与折射: 真实的光学现象
- 全局光照: IBL (基于图像的光照)
7.3 推荐练习
多光源场景
- 实现点光源、聚光灯、平行光
- 阴影映射基础
- 光源衰减函数
材质库
- 创建不同材质预设 (金属、木头、塑料)
- 实时调整参数观察效果
- 组合多张贴图
法线贴图生成
- 从高度图生成法线贴图
- Sobel算子应用
- 强度控制
PBR工作流
- 完整的PBR材质系统
- IBL环境光照
- 实时GI近似
7.4 参考资源
继续探索光与物质的物理奥秘吧!最后一篇见! ✨