第19章:光照模型与材质

你好, junior!欢迎来到渲染学习中最迷人的一章。如果说几何和变换是3D世界的骨架,那么光照就是赋予这个世界灵魂的画笔。没有光,3D场景只是一堆扁平的色块;有了光,同样的几何体就能呈现出金属的冷峻、丝绸的柔滑、石头的粗粝。


目录

  1. 为什么我们需要光照:扁平着色 vs 光照着色
  2. 环境光:基础照明
  3. 漫反射(Lambert):光线方向与法线的点积
  4. 平行光:像太阳一样的平行光线
  5. 点光源:随距离衰减的光
  6. 聚光灯:锥形角度与衰减
  7. 镜面高光:Phong反射模型
  8. Blinn-Phong:为什么它比Phong更好
  9. 完整的Phong反射模型:环境光 + 漫反射 + 镜面反射
  10. 场景中的多光源
  11. 常见问题 Q&A

1. 为什么我们需要光照:扁平着色 vs 光照着色

1.1 现实生活中的类比

想象你走进一个完全黑暗的密室,手里有一个红色的皮球。你打开手电筒照向皮球——哇,它突然有了形状! 你能看到球的一侧亮、一侧暗,甚至能看到表面微微反光。关掉手电筒,皮球又变回了一个模糊的红色轮廓。

这就是光照的魔力:光让我们感知到形状、材质和深度。

1.2 本质是什么

在计算机图形学中,"没有光照"意味着每个像素都用同一种颜色绘制,不管它朝向哪里。这叫做扁平着色(Flat Shading)。它的结果是:一个球看起来像一个扁平的圆盘,一个立方体看起来像一个六边形。

**光照着色(Lit Shading)**则根据每个表面点与光源的相对关系,计算出不同的颜色。核心思想是:

一个表面的亮度,取决于它"朝向"光源的程度。

1.3 扁平着色的代码示例

让我们看看没有光照时,一个3D场景长什么样。打开 akira-graphics/lights/ambient-light.html,把环境光强度设为 [0, 0, 0]——你会看到一片漆黑。再设为 [1, 1, 1]——所有物体都亮得没有层次。

// 扁平着色:没有光照计算,直接输出颜色
void main() {
    gl_FragColor.rgb = materialReflection; // 直接就是材质颜色
    gl_FragColor.a = 1.0;
}

这就像给所有物体刷了一层均匀的油漆,不管阳光从哪个角度照过来,看起来都一模一样。

1.4 常见误区

误区1:"光照只是让东西变亮"

不对!光照的核心是创造明暗对比。没有暗部,亮部就不存在。正是明暗交界让我们的大脑感知到"这是圆的""这是凹进去的"。

误区2:"光照是后期加的效果"

光照是实时计算的。每个像素的颜色都要根据光源位置、表面朝向、观察者位置重新算一遍。这就是为什么光照是GPU最繁重的任务之一。

1.5 动手试一试

Try it yourself:打开 ambient-light.html,尝试把 ambientLight 改成 [0.2, 0.2, 0.2][1.0, 1.0, 1.0]。观察物体的立体感有什么变化?为什么 [1,1,1] 时所有物体看起来都像剪纸?


2. 环境光:基础照明

2.1 现实生活中的类比

想象你在一个阴天站在户外。太阳被云层遮住了,你看不到明显的阴影,但周围的一切仍然能被看见。为什么?因为阳光被云层、空气、地面无数次反射后,从四面八方均匀地照亮了你。

这就是环境光(Ambient Light)——不是来自某个特定光源,而是来自"整个世界"的间接光照。

2.2 本质是什么

环境光是光照模型中最简单的部分。它假设:无论表面朝向哪里,都接收到一个恒定的基础亮度。

公式极其简单:

环境光颜色 = 环境光强度 × 材质反射率

用数学表达:

Ambient = Ia × Ka

其中:

  • Ia(Intensity ambient):环境光的RGB颜色/强度
  • Ka(Reflection ambient):材质对环境光的反射率

2.3 代码示例

// 顶点着色器
precision highp float;
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// 片元着色器
precision highp float;

uniform vec3 ambientLight;        // 环境光颜色,例如 [0.5, 0.5, 0.5]
uniform vec3 materialReflection;  // 材质反射率,例如 [0, 0, 1] 表示蓝色

void main() {
    // 环境光计算:简单的乘法
    gl_FragColor.rgb = ambientLight * materialReflection;
    gl_FragColor.a = 1.0;
}

2.4 公式推导

为什么用乘法?

想象一面白墙(反射率 [1, 1, 1])和一束白光(强度 [1, 1, 1])。墙面看起来是白色:[1,1,1] × [1,1,1] = [1,1,1]

如果墙面是蓝色的(只反射蓝光,吸收红绿光),反射率是 [0, 0, 1]。白光 [1,1,1] 照上去:[1,1,1] × [0,0,1] = [0,0,1]——只有蓝光被反射,所以看起来是蓝色。

如果环境光很暗 [0.2, 0.2, 0.2],蓝色墙面:[0.2,0.2,0.2] × [0,0,1] = [0,0,0.2]——很暗的蓝色。

乘法的本质:光源提供了"可用的光",材质决定了"吸收哪些、反射哪些"。

2.5 常见误区

误区:"环境光可以代替真正的间接光照"

环境光是一个极大的简化。真实世界的间接光照非常复杂:红色墙壁会把红光反射到旁边的白色物体上,这叫做"颜色溢出"。环境光完全忽略了这些细节,只是给一个统一的底色。在高质量渲染中,我们会用全局光照(Global Illumination)来替代简陋的环境光。

2.6 动手试一试

Try it yourself:在 ambient-light.html 中,把四个物体的 materialReflection 分别改成 [1,1,1](白)、[0.5,0.5,0.5](灰)、[0.1,0.1,0.1](深灰)、[0,0,0](黑)。观察在相同环境光下,为什么黑色物体几乎看不见?这说明了材质反射率的什么作用?


3. 漫反射(Lambert):光线方向与法线的点积

3.1 现实生活中的类比

拿一张白纸,用手电筒垂直照射它——很亮对吧?现在慢慢倾斜纸张,让它"侧对"手电筒。你会发现纸越来越暗,直到完全平行于光线时,几乎看不见反光。

为什么?因为单位面积上接收到的光子数量,取决于表面与光线的夹角

3.2 本质是什么

**漫反射(Diffuse Reflection)**描述的是:光线照射到粗糙表面后,向四面八方均匀散射的现象。像粉笔、泥土、未上漆的木头,都是典型的漫反射材质。

关键洞察来自18世纪的物理学家朗伯(Johann Heinrich Lambert):

表面的亮度,与光线方向和表面法线夹角的余弦成正比。

3.3 点积的推导

为什么偏偏是余弦?让我们一步步推导。

步骤1:理解法线(Normal)

法线是一个垂直于表面的单位向量。它就像一根从表面"长出来"的小箭头,告诉我们"这个面朝哪个方向"。

步骤2:理解光线方向

光线方向 L 是从表面点指向光源的单位向量(注意:有些文献定义相反,从光源指向表面,这会影响后续公式的符号)。

步骤3:几何推导

想象一束横截面积为 A 的平行光,以角度 θ 照射到平面上。

  • 当光线垂直照射(θ = 0°)时,光斑面积就是 A,单位面积能量最大。
  • 当光线倾斜照射(θ = 60°)时,同样的光束被"摊平"到一个更大的面积上,单位面积能量减小。
  • 具体关系:光斑面积 = A / cos(θ),所以单位面积能量 ∝ cos(θ)

步骤4:点积就是余弦

在数学中,两个单位向量的**点积(Dot Product)**等于它们夹角的余弦:

L · N = |L| × |N| × cos(θ) = cos(θ)  (因为都是单位向量,长度为1)

所以:

漫反射亮度 = max(L · N, 0)

为什么要 max(..., 0)?因为如果光线从表面背面照过来(夹角大于90°),cos(θ) 会变成负数。但背面被照亮是不合理的,所以我们把它截断到0。

3.4 完整的漫反射公式

Diffuse = Id × Kd × max(L · N, 0)

其中:

  • Id:光源的漫反射颜色/强度
  • Kd:材质的漫反射反射率
  • L:从表面指向光源的单位向量
  • N:表面的单位法线向量

3.5 代码示例

// 顶点着色器
precision highp float;
attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;

varying vec3 vNormal;  // 把法线传给片元着色器

void main() {
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// 片元着色器
precision highp float;

uniform vec3 lightDirection;  // 光源方向(假设是平行光)
uniform vec3 lightColor;      // 光源颜色
uniform vec3 materialDiffuse; // 材质漫反射颜色

varying vec3 vNormal;

void main() {
    // 步骤1:归一化光线方向
    vec3 L = normalize(-lightDirection);  // 注意方向:从表面指向光源
    
    // 步骤2:归一化法线(再次确认)
    vec3 N = normalize(vNormal);
    
    // 步骤3:计算点积(即夹角余弦)
    float cosTheta = max(dot(L, N), 0.0);
    
    // 步骤4:计算漫反射
    vec3 diffuse = lightColor * materialDiffuse * cosTheta;
    
    gl_FragColor = vec4(diffuse, 1.0);
}

3.6 常见误区

误区1:"法线不需要归一化"

大错! 点积公式 L · N = cos(θ) 的前提是 LN 都是单位向量(长度为1)。如果法线被矩阵变换后变长了,点积结果就不再是余弦值,光照会完全错乱。所以每次用法线前都要 normalize()

误区2:"点积结果是角度"

点积结果是余弦值,不是角度。cos(0°) = 1cos(60°) = 0.5cos(90°) = 0。这个值范围是 [-1, 1],正好适合作为亮度系数。

误区3:"忽略 max(..., 0) 也没关系"

如果不截断负数,当光线从背面照来时,表面反而会"发光"——这在物理上是不可能的(除非你自己在做科幻特效)。

3.7 动手试一试

Try it yourself:在 directional-light.html 中,把 max(dot(...), 0.0) 改成单纯的 dot(...)。旋转视角到物体背面,观察会发生什么奇怪的现象?然后试着把 0.0 改成 0.5,看看"背光面也有一点亮度"的效果——这其实是某种廉价的全局光照模拟!


4. 平行光:像太阳一样的平行光线

4.1 现实生活中的类比

站在地球上仰望太阳。太阳离我们约1.5亿公里,所以到达地球的光线几乎是完全平行的。无论你站在北京还是上海,太阳光的方向都基本相同。

**平行光(Directional Light)**就是模拟这种"无限远光源"的模型。

4.2 本质是什么

平行光有两大特点:

  1. 所有光线方向相同:不像灯泡那样光线四散,平行光的光线像箭一样整齐划一。
  2. 没有衰减:因为光源在"无限远处",物体离光源的距离可以忽略不计,所以亮度不随距离变化。

4.3 为什么平行光只需要方向

对于点光源,我们需要知道光源的位置,因为每个表面点到光源的方向都不同。但对于平行光,所有点收到的光线方向都一样,所以只需要一个方向向量就够了。

在代码中,平行光的方向通常定义为"光源指向场景的方向"。例如 [-1, -1, 0] 表示光从右上方射来。

4.4 坐标空间转换的关键

这里有一个容易踩的坑:光照计算必须在同一坐标空间下进行!

// 顶点着色器中的关键代码
uniform vec3 directionalLight;  // 光源方向(世界空间)
uniform mat4 viewMatrix;        // 视图矩阵

void main() {
    // 把光源方向从世界空间转换到视图空间
    vec4 invDirectional = viewMatrix * vec4(directionalLight, 0.0);
    vDir = -invDirectional.xyz;  // 取反,得到从表面指向光源的方向
    
    // 法线也要转换到视图空间
    vNormal = normalize(normalMatrix * normal);
}

注意 vec4(directionalLight, 0.0) 中的 0.0!方向向量没有位置,所以w分量是0。如果是位置(如点光源),w分量是1。这个区别决定了矩阵变换时是否受到平移影响。

4.5 完整代码示例

// 顶点着色器
precision highp float;
attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat3 normalMatrix;
uniform vec3 directionalLight;

varying vec3 vNormal;
varying vec3 vDir;

void main() {
    // 将平行光方向转换到视图空间
    vec4 invDirectional = viewMatrix * vec4(directionalLight, 0.0);
    vDir = -invDirectional.xyz;
    
    // 转换法线到视图空间
    vNormal = normalize(normalMatrix * normal);
    
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// 片元着色器
precision highp float;

uniform vec3 ambientLight;
uniform vec3 materialReflection;
uniform vec3 directionalLightColor;

varying vec3 vNormal;
varying vec3 vDir;

void main() {
    // 计算光线与法线夹角的余弦
    float cos = max(dot(normalize(vDir), vNormal), 0.0);
    
    // 计算漫反射
    vec3 diffuse = cos * directionalLightColor;
    
    // 合成:环境光 + 漫反射,再乘以材质反射率
    gl_FragColor.rgb = (ambientLight + diffuse) * materialReflection;
    gl_FragColor.a = 1.0;
}

4.6 常见误区

误区:"平行光和点光源只是参数不同"

不对!平行光用方向(direction),点光源用位置(position)。这是两种完全不同的数学模型。如果你给平行光一个位置,或者给点光源只传方向,结果都会错得离谱。

4.7 动手试一试

Try it yourself:在 directional-light.html 中,把 directionalLight 的值从 [1, 0, 0] 改成 [0, 1, 0](从上方)、[1, 1, 0](斜上方)、[0, -1, 0](从下方)。观察物体的明暗分布如何变化。特别注意当光线从下方来时,物体的"亮部"在哪里——这符合你的直觉吗?


5. 点光源:随距离衰减的光

5.1 现实生活中的类比

点一根蜡烛,把手靠近火焰——很烫很亮对吧?慢慢把手移远,热量和亮度都迅速下降。这就是**点光源(Point Light)**的特征:光线从一个点向四面八方发射,并且亮度随距离衰减。

灯泡、蜡烛、篝火、星星(从地球上看),都是点光源。

5.2 本质是什么

点光源与平行光的核心区别:

特性 平行光 点光源
定义 方向向量 位置向量
光线方向 处处相同 因点而异
距离衰减

对于点光源,每个表面点都要单独计算:

  1. 从表面点指向光源的方向
  2. 表面点到光源的距离
  3. 根据距离计算衰减

5.3 反平方定律的推导

为什么亮度随距离衰减?这来自物理学中的反平方定律(Inverse Square Law)

想象点光源发出的光像一个不断膨胀的球壳。在距离 r 处,球壳的表面积是 4πr²。同样的光能量被分散到更大的面积上,所以单位面积的能量与 成反比。

衰减系数 = 1 / r²

但在实际图形学中,纯 1/r² 有个问题:当 r 很小时,亮度会爆炸到无穷大。所以我们通常用一个修正的衰减公式

衰减 = 1 / (a × r² + b × r + c)

其中 a, b, c 是可以调节的参数。在 point-light.html 中,衰减因子是 [0.05, 0, 1]

衰减 = 1 / (0.05 × r² + 0 × r + 1) = 1 / (0.05r² + 1)

这样即使 r = 0,分母也是1,不会出现除以零的错误。

5.4 完整代码示例

// 顶点着色器
precision highp float;
attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;

varying vec3 vNormal;
varying vec3 vPos;  // 表面点在视图空间的位置

void main() {
    vec4 pos = modelViewMatrix * vec4(position, 1.0);
    vPos = pos.xyz;
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * pos;
}
// 片元着色器
precision highp float;

uniform vec3 ambientLight;
uniform vec3 materialReflection;
uniform vec3 pointLightColor;
uniform vec3 pointLightPosition;
uniform mat4 viewMatrix;
uniform vec3 pointLightDecayFactor;

varying vec3 vNormal;
varying vec3 vPos;

void main() {
    // 步骤1:计算从表面指向光源的方向
    vec3 dir = (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos;
    
    // 步骤2:计算距离(用于衰减)
    float dis = length(dir);
    
    // 步骤3:归一化方向向量
    vec3 L = normalize(dir);
    
    // 步骤4:计算与法线的夹角余弦
    float cos = max(dot(L, vNormal), 0.0);
    
    // 步骤5:计算衰减
    float decay = min(1.0, 1.0 / 
        (pointLightDecayFactor.x * pow(dis, 2.0) 
       + pointLightDecayFactor.y * dis 
       + pointLightDecayFactor.z));
    
    // 步骤6:计算漫反射(带衰减)
    vec3 diffuse = decay * cos * pointLightColor;
    
    // 步骤7:合成颜色
    gl_FragColor.rgb = (ambientLight + diffuse) * materialReflection;
    gl_FragColor.a = 1.0;
}

5.5 常见误区

误区1:"点光源的方向可以直接用 uniform 传"

不行!点光源的方向因表面点而异。每个像素都要重新计算 光源位置 - 表面位置。这是点光源比平行光计算量大的主要原因。

误区2:"衰减是可选的"

如果不加衰减,点光源就变成了"无限亮的灯泡"——无论多远都一样亮,这在物理上完全错误,视觉上也很假。

误区3:"距离用 world space 计算也可以"

可以,但前提是所有相关量都在同一空间。如果光源位置是世界空间,表面位置也必须是世界空间。在示例代码中,我们把光源位置乘以 viewMatrix 转换到视图空间,因为 vPos 也是视图空间的。混合空间会导致错误!

5.6 动手试一试

Try it yourself:在 point-light.html 中,修改 pointLightDecayFactor[0, 0, 1](无衰减)、[0.5, 0, 1](强衰减)、[0, 1, 1](线性衰减)。观察光晕的范围变化。然后尝试把光源位置从 [3, 3, 0] 改成 [0, 5, 0](正上方),看看物体的明暗分布如何改变。


6. 聚光灯:锥形角度与衰减

6.1 现实生活中的类比

想象舞台上的聚光灯:它有一个明确的光锥,只有在锥形范围内的演员被照亮,范围外的则处于黑暗中。而且,光锥中心最亮,边缘逐渐变暗——这叫做边缘衰减(Falloff)

手电筒、车头灯、舞台灯,都是聚光灯。

6.2 本质是什么

聚光灯 = 点光源 + 方向限制。它在点光源的基础上,增加了一个条件:只有位于特定角度范围内的表面才会被照亮。

聚光灯需要三个额外参数:

  1. 方向(Direction):光锥指向哪里
  2. 角度(Angle):光锥的开口大小
  3. 衰减因子(Decay):与点光源相同

6.3 角度判断的推导

如何判断一个点是否在光锥内?

步骤1:定义向量

  • D:聚光灯的朝向(从光源指向外的方向)
  • L:从表面点指向光源的方向(注意:是从表面指向光源!)

步骤2:计算夹角余弦

如果表面点在光锥正前方,那么 -D(从光源指向外)和 L(从表面指向光源)的方向应该接近。等等,让我们重新理清楚方向:

  • 聚光灯朝向 D 表示"光往 D 的方向射"
  • 从光源指向表面点的方向是 -L(因为 L 是从表面指向光源)
  • 所以我们比较 -LD 的夹角

在示例代码中:

vec3 dir = (viewMatrix * vec4(spotLightDirection, 0.0)).xyz;
float ang = cos(spotLightAngle);
float r = step(ang, dot(invNormal, normalize(-dir)));

这里:

  • invNormal 是从表面指向光源的方向(已经归一化)
  • -dir 是聚光灯照射的方向
  • dot(invNormal, normalize(-dir)) 是这两个方向夹角的余弦
  • step(ang, ...) 如果余弦大于 ang(即夹角小于 spotLightAngle),返回1,否则返回0

为什么用余弦比较?

因为 cos(θ)θ ∈ [0, π] 上是单调递减的。角度越小,余弦越大。所以"夹角小于设定角度"等价于"余弦大于设定余弦"。

6.4 完整代码示例

// 片元着色器(聚光灯部分)
void main() {
    // 光线到点坐标的方向
    vec3 invLight = (viewMatrix * vec4(spotLightPosition, 1.0)).xyz - vPos;
    vec3 invNormal = normalize(invLight);
    
    // 光线到点坐标的距离,用来计算衰减
    float dis = length(invLight);
    
    // 聚光灯的朝向
    vec3 dir = (viewMatrix * vec4(spotLightDirection, 0.0)).xyz;
    
    // 通过余弦值判断夹角范围
    float ang = cos(spotLightAngle);
    float r = step(ang, dot(invNormal, normalize(-dir)));
    
    // 与法线夹角余弦
    float cos = max(dot(invNormal, vNormal), 0.0);
    
    // 计算衰减
    float decay = min(1.0, 1.0 /
        (spotLightDecayFactor.x * pow(dis, 2.0) 
       + spotLightDecayFactor.y * dis 
       + spotLightDecayFactor.z));
    
    // 计算漫反射(注意乘以 r,光锥外的点 r=0)
    vec3 diffuse = r * decay * cos * spotLightColor;
    
    // 合成颜色
    gl_FragColor.rgb = (ambientLight + diffuse) * materialReflection;
    gl_FragColor.a = 1.0;
}

6.5 常见误区

误区1:"聚光灯角度是度数"

在代码中,spotLightAngle弧度(radian)Math.PI / 12 约等于15度。如果你传了15而不是 Math.PI / 12,光锥会大得离谱。

误区2:"step 函数有平滑过渡"

step(edge, x)硬截断x >= edge 时返回1,否则返回0。这意味着光锥边缘是锐利的。如果你想要平滑的边缘过渡,应该用 smoothstep

float r = smoothstep(ang, ang + 0.1, dot(invNormal, normalize(-dir)));

这会创建一个从 angang + 0.1 的柔和过渡带。

6.6 动手试一试

Try it yourself:在 spot-light.html 中,把 step 换成 smoothstep(cos(spotLightAngle), cos(spotLightAngle * 0.5), dot(...))。观察光锥边缘是否变得柔和?然后尝试把聚光灯方向改成 [0, -1, 0](向下照射),位置改成 [0, 5, 0],模拟一盏吊灯的效果。


7. 镜面高光:Phong反射模型

7.1 现实生活中的类比

看着一面镜子,你能看到光源的清晰倒影。或者看着一颗抛光的苹果,在亮部有一个小小的、刺眼的亮斑——那就是镜面高光(Specular Highlight)

与漫反射不同,镜面反射不是向四面八方散射,而是沿着特定方向反射。只有当观察者正好位于反射方向上时,才能看到最亮的高光。

7.2 本质是什么

镜面反射遵循反射定律:入射角等于反射角。在向量术语中:

反射光方向 = 入射光关于法线的对称方向

然后,我们计算"观察者方向"与"反射光方向"的夹角。夹角越小,高光越亮。

7.3 反射向量的推导

给定入射方向 L(指向光源)和法线 N,如何求反射方向 R

步骤1:分解入射向量

L 分解为垂直于法线的分量和平行于法线的分量:

L_parallel = (L · N) × N      // 沿法线方向的分量
L_perp = L - L_parallel        // 垂直于法线的分量

步骤2:反射就是翻转平行分量

反射后,垂直分量不变,平行分量反向:

R = L_perp - L_parallel
  = L - 2 × L_parallel
  = L - 2 × (L · N) × N

在GLSL中,这个计算已经内置了:

vec3 reflectionLight = reflect(-L, N);

注意 reflect 函数期望的入射方向是指向表面的,所以我们传 -L

7.4 Phong高光公式

Specular = Is × Ks × max(R · V, 0)^shininess

其中:

  • Is:光源的镜面反射颜色/强度
  • Ks:材质的镜面反射系数
  • R:反射光方向(已归一化)
  • V:观察者方向(从表面指向相机,已归一化)
  • shininess:光泽度,值越大高光越集中

为什么有 shininess 次方?

因为真实世界的镜面反射不是完美的。表面越粗糙,反射光会稍微散开。shininess 模拟了这个"散开程度"。

  • shininess = 1:非常粗糙,高光很散
  • shininess = 50:比较光滑,高光集中
  • shininess = 200:像镜子一样,高光是一个小点

数学上,x^nn 很大时,只有 x 接近1的值才会保留。这意味着只有视角非常接近反射方向时才能看到高光。

7.5 完整代码示例

// 顶点着色器
precision highp float;
attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
uniform vec3 cameraPosition;

varying vec3 vNormal;
varying vec3 vPos;
varying vec3 vCameraPos;

void main() {
    vec4 pos = modelViewMatrix * vec4(position, 1.0);
    vPos = pos.xyz;
    vCameraPos = (viewMatrix * vec4(cameraPosition, 1.0)).xyz;
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * pos;
}
// 片元着色器
precision highp float;

uniform vec3 ambientLight;
uniform vec3 materialReflection;
uniform vec3 pointLightColor;
uniform vec3 pointLightPosition;
uniform mat4 viewMatrix;
uniform vec3 pointLightDecayFactor;

varying vec3 vNormal;
varying vec3 vPos;
varying vec3 vCameraPos;

void main() {
    // 光线到点坐标的方向
    vec3 dir = (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos;
    float dis = length(dir);
    dir = normalize(dir);
    
    // 与法线夹角余弦(漫反射)
    float cos = max(dot(dir, vNormal), 0.0);
    
    // ========== 镜面反射计算 ==========
    // 步骤1:计算反射光方向
    vec3 reflectionLight = reflect(-dir, vNormal);
    
    // 步骤2:计算观察者方向
    vec3 eyeDirection = vCameraPos - vPos;
    eyeDirection = normalize(eyeDirection);
    
    // 步骤3:计算观察者方向与反射方向的夹角余弦
    float eyeCos = max(dot(eyeDirection, reflectionLight), 0.0);
    
    // 步骤4:计算镜面反射(带光泽度)
    float specular = 2.0 * pow(eyeCos, 50.0);
    // ==================================
    
    // 计算衰减
    float decay = min(1.0, 1.0 /
        (pointLightDecayFactor.x * pow(dis, 2.0) 
       + pointLightDecayFactor.y * dis 
       + pointLightDecayFactor.z));
    
    // 计算漫反射
    vec3 diffuse = decay * cos * pointLightColor;
    
    // 合成颜色:镜面反射直接加在最终结果上
    gl_FragColor.rgb = specular + (ambientLight + diffuse) * materialReflection;
    gl_FragColor.a = 1.0;
}

7.6 常见误区

误区1:"镜面反射也要乘以材质颜色"

仔细看代码:specular 是直接加上的,没有乘 materialReflection。为什么?因为镜面高光通常是光源的颜色(比如白炽灯的高光是白的),而不是材质的颜色。金属除外——金属的镜面反射会带有材质颜色,但那是更高级的PBR材质模型了。

误区2:"shininess 越大越亮"

不对!shininess 越大,高光范围越小,但峰值亮度不变。因为 pow(x, n)x ∈ [0,1] 上的积分随 n 增大而减小——能量守恒要求总亮度大致不变,只是更集中了。

误区3:"reflect 函数可以直接用 L"

GLSL的 reflect(I, N) 要求 I入射方向(指向表面)。而我们的 L 是从表面指向光源的,所以要传 -L

7.7 动手试一试

Try it yourself:在 specular.html 中,把 pow(eyeCos, 50.0) 的50改成5、20、100、200。观察高光斑的大小变化。然后把 2.0 * 改成 0.5 *5.0 *,观察高光强度变化。试着理解:为什么球体上的高光是一个圆形亮斑,而不是一条线?


8. Blinn-Phong:为什么它比Phong更好

8.1 现实生活中的类比

Phong模型就像你站在房间里,先算出光从镜子反射到哪里,再看你是不是正好站在那里。Blinn-Phong则更聪明:它直接问"你的视线和光线方向的'中间方向',是不是接近法线?"

8.2 本质是什么

Blinn-Phong是Phong模型的一个改进版,由Jim Blinn在1977年提出。它用**半程向量(Halfway Vector)**替代了Phong的反射向量。

半程向量 = 光线方向 L 和观察者方向 V 的中间方向:

H = (L + V) / |L + V|

然后,Blinn-Phong计算的是法线 N 与半程向量 H 的夹角,而不是反射向量 R 与视线 V 的夹角。

8.3 为什么Blinn-Phong更好

原因1:计算更快

Phong需要计算 reflect(-L, N),这涉及向量分解和减法。Blinn-Phong只需要加法和归一化:H = normalize(L + V)。在早期GPU上,这节省了不少计算。

原因2:当视线与光源方向相反时更稳定

在某些极端角度,Phong模型的反射向量计算会产生奇怪的结果。Blinn-Phong更稳定。

原因3:更接近某些真实材质

Blinn-Phong的高光形状在某些情况下比Phong更"自然"。

8.4 公式对比

Phong:

Specular = Ks × max(R · V, 0)^shininess

Blinn-Phong:

H = normalize(L + V)
Specular = Ks × max(N · H, 0)^(shininess × 4)

注意:Blinn-Phong的指数通常要乘以4左右,才能得到与Phong相似的高光大小。因为 N · H 的值通常比 R · V 大。

8.5 代码对比

// Phong 镜面反射
vec3 reflectionLight = reflect(-dir, vNormal);
vec3 eyeDirection = normalize(vCameraPos - vPos);
float eyeCos = max(dot(eyeDirection, reflectionLight), 0.0);
float specular = specularFactor * pow(eyeCos, shininess);
// Blinn-Phong 镜面反射
vec3 eyeDirection = normalize(vCameraPos - vPos);
vec3 halfVector = normalize(dir + eyeDirection);  // 半程向量
float halfCos = max(dot(vNormal, halfVector), 0.0);
float specular = specularFactor * pow(halfCos, shininess * 4.0);

8.6 常见误区

误区:"Blinn-Phong和Phong完全等价"

它们只是近似等价。在某些角度下,高光形状有明显差异。Blinn-Phong的高光在边缘处更"圆",Phong的更"尖"。选择哪个取决于你想要的效果。

8.7 动手试一试

Try it yourself:把 specular.html 中的Phong高光计算改成Blinn-Phong。对比两者的视觉效果。特别注意当光源、物体、相机几乎在一条直线上时(比如相机正对着光源照射的球面),两种模型的差异最明显。


9. 完整的Phong反射模型:环境光 + 漫反射 + 镜面反射

9.1 现实生活中的类比

想象一个阳光明媚的下午,你看着一辆红色跑车:

  • 环境光:即使没有直射阳光,车底和缝隙里也不是全黑的——周围建筑的反射光提供了基础照明。
  • 漫反射:车身呈现红色,因为车漆吸收了其他颜色的光,只反射红光。车身朝向太阳的一面更亮,背阴面更暗。
  • 镜面反射:车顶上有一个刺眼的白色亮斑——那是太阳的直接反射。

Phong模型 = 环境光 + 漫反射 + 镜面反射,三者叠加,构成了我们看到的大部分光照效果。

9.2 本质是什么

Phong模型是一个局部光照模型(Local Illumination Model)。"局部"意味着:每个表面点的颜色只取决于该点、光源和相机,不考虑场景中其他物体的影响(比如阴影、反射、折射)。

完整公式:

I = Ia × Ka + Id × Kd × max(L · N, 0) + Is × Ks × max(R · V, 0)^shininess

或者写成:

最终颜色 = 环境光项 + 漫反射项 + 镜面反射项

9.3 材质参数的含义

参数 名称 含义 典型值
Ka 环境反射率 材质反射环境光的能力 通常等于 Kd
Kd 漫反射率 材质的本色 红色塑料 = [1, 0, 0]
Ks 镜面反射率 材质"多像镜子" 金属 = 高,塑料 = 低
shininess 光泽度 高光有多集中 1~200+

9.4 代码示例(来自 phong.html)

// 片元着色器中的 Phong 反射计算
float getSpecular(vec3 dir, vec3 normal, vec3 eye) {
    vec3 reflectionLight = reflect(-dir, normal);
    float eyeCos = max(dot(eye, reflectionLight), 0.0);
    return specularFactor * pow(eyeCos, shininess);
}

vec4 phongReflection(vec3 pos, vec3 normal, vec3 eye) {
    float specular = 0.0;
    vec3 diffuse = vec3(0);
    
    // 处理平行光
    for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
        vec3 dir = directionalLightDirection[i];
        if(dir.x == 0.0 && dir.y == 0.0 && dir.z == 0.0) continue;
        vec4 d = viewMatrix * vec4(dir, 0.0);
        dir = normalize(-d.xyz);
        float cos = max(dot(dir, normal), 0.0);
        diffuse += cos * directionalLightColor[i];
        specular += getSpecular(dir, normal, eye);
    }
    
    // 处理点光源...
    // 处理聚光灯...
    
    return vec4(diffuse, specular);
}

void main() {
    vec3 eyeDirection = normalize(vCameraPos - vPos);
    vec4 phong = phongReflection(vPos, vNormal, eyeDirection);
    
    // 合成颜色:镜面反射直接加,漫反射和环境光乘材质颜色
    gl_FragColor.rgb = phong.w + (phong.xyz + ambientLight) * materialReflection;
    gl_FragColor.a = 1.0;
}

9.5 常见误区

误区1:"三项可以任意组合"

严格来说,Phong模型假设 Ka + Kd + Ks <= 1(能量守恒)。如果三者之和大于1,物体就会"发光"——在物理上不合理(除非是自发光物体)。

误区2:"Phong模型是物理正确的"

Phong模型是经验模型,不是基于物理的。它"看起来对",但在能量守恒、菲涅尔效应等方面有缺陷。现代渲染使用**PBR(基于物理的渲染)**来替代Phong,但Phong仍然是学习光照的最佳起点。

9.6 动手试一试

Try it yourself:在 phong.html 中,修改 Material 的构造参数。创建一个 new Material(new Color('#ffffff'), 0.0, 50)(无高光的白色球)和 new Material(new Color('#333333'), 5.0, 200)(深色金属球)。观察不同材质参数如何改变物体的"质感"。


10. 场景中的多光源

10.1 现实生活中的类比

走进一间装修精美的餐厅:头顶有吊灯,墙上有壁灯,桌上还有蜡烛。每个光源都贡献了一部分亮度,最终你看到的画面是所有光源的叠加

10.2 本质是什么

在计算机图形学中,光照是线性可叠加的。这意味着:

总光照 = 光源1的贡献 + 光源2的贡献 + 光源3的贡献 + ...

这是**叠加原理(Superposition Principle)**的应用。每个光源独立计算其漫反射和镜面反射,然后相加。

10.3 实现多光源的方法

phong.html 中,作者设计了一个优雅的 Phong 类来管理多光源:

class Phong {
    constructor(ambientLight = [0.5, 0.5, 0.5]) {
        this.ambientLight = ambientLight;
        this.directionalLights = new Set();
        this.pointLights = new Set();
        this.spotLights = new Set();
    }

    addLight(light) {
        const {position, direction, color, decay, angle} = light;
        if(!position && !direction) throw new TypeError('invalid light');
        light.color = color || [1, 1, 1];
        if(!position) this.directionalLights.add(light);
        else {
            light.decay = decay || [0, 0, 1];
            if(!angle) {
                this.pointLights.add(light);
            } else {
                this.spotLights.add(light);
            }
        }
    }
    // ...
}

10.4 GLSL中的数组与循环

GLSL支持数组和循环,但有一个限制:循环次数必须是常量或编译时可确定的。所以代码中用了一个固定上限 MAX_LIGHT_COUNT = 16

#define MAX_LIGHT_COUNT 16

uniform vec3 directionalLightDirection[MAX_LIGHT_COUNT];
uniform vec3 directionalLightColor[MAX_LIGHT_COUNT];
// ...

for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 dir = directionalLightDirection[i];
    // 用 [0,0,0] 表示"这个光源槽位没有光源"
    if(dir.x == 0.0 && dir.y == 0.0 && dir.z == 0.0) continue;
    // 计算该光源的贡献...
}

10.5 Uniform数组的数据填充

在JavaScript端,需要把光源数据打包进 Float32Array

const MAX_LIGHT_COUNT = 16;

// 创建数组
lightData.directionalLightDirection = { 
    value: new Float32Array(MAX_LIGHT_COUNT * 3) 
};

// 填充数据
[...this.directionalLights].forEach((light, idx) => {
    lightData.directionalLightDirection.value.set(light.direction, idx * 3);
    lightData.directionalLightColor.value.set(light.color, idx * 3);
});

10.6 性能考虑

每增加一个光源,片元着色器就要多执行一轮计算。在早期的GPU上,多光源是性能杀手。现代GPU强大得多,但仍然建议:

  1. 限制光源数量:通常4~8个动态光源就足够了。
  2. 使用延迟渲染(Deferred Shading):对于大量光源,延迟渲染比正向渲染(Forward Shading)高效得多。
  3. 只计算受影响的光源:用光照剔除(Light Culling)避免计算对当前像素无贡献的光源。

10.7 常见误区

误区:"光源越多越好"

太多光源会让画面变得"平"——到处都是亮的,没有暗部来衬托。好的光照设计需要明暗对比,不是越多越好。

误区:"所有光源类型可以混用一种计算"

平行光、点光源、聚光灯的计算方式不同。平行光没有衰减,点光源有距离衰减,聚光灯还有角度限制。混用会导致错误。

10.8 动手试一试

Try it yourself:在 phong.html 中,添加第三个点光源 position: [0, -3, 0], color: [1, 1, 0](从下方的黄光)。观察三个光源同时作用时的效果。然后试着添加一个聚光灯,模拟舞台追光灯的效果。注意:每种类型的光源都要调用 phong.addLight() 添加。


11. 常见问题 Q&A

Q1:为什么我的光照看起来"不对",物体一半黑一半白?

A:最可能的原因是法线没有正确归一化,或者法线矩阵(normalMatrix)没有正确计算。记住:经过非均匀缩放后,法线不能直接乘以模型矩阵,而需要用到 normalMatrix = (modelMatrix⁻¹)ᵀ。在OGL库中,这个矩阵通常会自动计算并传入shader。

Q2:为什么背面完全是黑的?

A:因为 max(dot(L, N), 0.0) 把背面的光照截断为0了。这在物理上是正确的——背面确实不应该被直射光照亮。如果你觉得太黑,可以:

  1. 提高环境光强度
  2. 添加更多光源从不同角度照射
  3. 使用双面光照(为背面翻转法线再算一遍)

Q3:镜面高光为什么有时候出现在奇怪的位置?

A:检查三个方向向量:

  1. L(指向光源)是否正确?
  2. N(法线)是否归一化?
  3. V(指向观察者)是否从表面点出发?

最常见的问题是 reflect(-L, N) 中的符号错误。

Q4:点光源的衰减公式为什么是 1/(ar² + br + c),而不是简单的 1/r²

A:纯 1/r²r → 0 时趋向无穷大,导致靠近光源的表面亮得发白的artifact。加入线性项和常数项可以控制这个行为。常数项 c 确保即使 r = 0 也有合理的亮度。

Q5:Phong模型和PBR有什么关系?

A:Phong模型是经验模型——参数(Ka, Kd, Ks, shininess)是"调出来"的,没有直接的物理意义。PBR(基于物理的渲染)使用金属度(Metallic)、**粗糙度(Roughness)**等参数,严格遵循能量守恒和微表面模型。PBR更复杂但更真实,是现代游戏和电影的标准。Phong是理解光照原理的最佳入门。

Q6:为什么我的多光源场景变得很卡?

A:每增加一个光源,每个像素都要多算一遍光照。解决方案:

  1. 减少光源数量
  2. 使用延迟渲染
  3. 把小的、远的光源"烘焙"到纹理中(Lightmap)
  4. 使用环境光遮蔽(AO)来模拟间接光照,而不是用大量点光源

Q7:环境光的颜色应该怎么选?

A:环境光通常设置为场景主光源颜色的暗版本。例如,如果主光源是暖黄色 [1, 0.9, 0.8],环境光可以设为 [0.2, 0.18, 0.16]。在户外场景中,环境光常常带一点蓝色,模拟天空的散射光。

Q8:法线矩阵为什么不是普通的模型矩阵?

A:假设你把一个球沿Y轴缩放2倍。顶点被拉长了,但法线如果也直接缩放,就不再垂直于表面了。数学上,法线需要使用逆矩阵的转置来变换:normalMatrix = transpose(inverse(modelMatrix))。对于只包含旋转和平移的矩阵,普通模型矩阵的3x3部分就够了(因为旋转矩阵的逆等于转置)。


总结

恭喜你读完了这一章!让我们回顾一下核心要点:

概念 核心公式 关键理解
环境光 Ia × Ka 无处不在的基础亮度
漫反射 Id × Kd × max(L·N, 0) 表面朝向决定亮度
平行光 方向向量,无衰减 模拟太阳等远光源
点光源 位置向量,有衰减 模拟灯泡等近光源
聚光灯 点光源 + 角度限制 模拟手电筒、舞台灯
Phong高光 Is × Ks × max(R·V, 0)^n 视线与反射方向的夹角
Blinn-Phong Is × Ks × max(N·H, 0)^n 用半程向量优化计算
多光源 各光源贡献相加 光照的线性叠加性

记住:光照不是魔法,而是物理规律的近似。每一个公式背后都有直观的几何意义。当你理解了"为什么点积等于余弦"、"为什么衰减是反平方"、"为什么高光要取n次方",你就掌握了光照的本质。

下一章,我们将继续探索更高级的材质和纹理,让你的3D世界更加真实!


课后挑战:尝试修改 phong.html,实现一个简单的"日夜循环"效果——让平行光的方向随时间旋转,模拟太阳东升西落。观察物体的阴影和高光如何随太阳位置变化。提示:在 update 函数中用 Date.now() 计算角度,更新平行光方向。