You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

422 lines
19 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 24 | 如何模拟光照让3D场景更逼真
你好,我是月影。今天,我们接着来讲,怎么模拟光照。
上节课,我们讲了四种光照的漫反射模型。实际上,因为物体的表面材质不同,反射光不仅有漫反射,还有镜面反射。
![](https://static001.geekbang.org/resource/image/2a/d5/2ac147c6eb17d547a3ff355e58d65ed5.jpg "镜面反射与漫反射")
什么是镜面反射呢?如果若干平行光照射在表面光滑的物体上,反射出来的光依然平行,这种反射就是镜面反射。镜面反射的性质是,入射光与法线的夹角等于反射光与法线的夹角。
越光滑的材质,它的镜面反射效果也就越强。最直接的表现就是物体表面会有闪耀的光斑,也叫镜面高光。但并不是所有光都能产生镜面反射,我们上节课讲的四种光源中,环境光因为没有方向,所以不参与镜面反射。剩下的平行光、点光源、聚光灯这三种光源,都是能够产生镜面反射的有向光。
[![](https://static001.geekbang.org/resource/image/15/0f/15a2e5bcf5dc18b4e0e02efc9e79fc0f.jpeg "镜面高光")](https://commons.wikimedia.org)
那么今天我们就来讨论一下如何实现镜面反射然后将它和上节课的漫反射结合起来就可以实现标准的光照模型也就是Phong反射模型了从而能让我们实现的可视化场景更加接近于自然界的效果。
## 如何实现有向光的镜面反射?
首先,镜面反射需要同时考虑光的入射方向以及相机也就是观察者所在的方向。
[![](https://static001.geekbang.org/resource/image/f2/c9/f2f1bee42562acf44941aa2b077181c9.jpeg "观察者与光的入射方向")](https://blog.csdn.net/xyh930929/article/details/83418396)
接着我们再来说说怎么实现镜面反射效果一般来说需要4个步骤。
**第一步,求出反射光线的方向向量**。这里我们以点光源为例要求出反射光的方向我们可以直接使用GLSL的内置函数reflect这个函数能够返回一个向量相对于某个法向量的反射向量正好就是我们要的镜面反射结果。
```
// 求光源与点坐标的方向向量
vec3 dir = (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos;
// 归一化
dir = normalize(dir);
// 求反射向量
vec3 reflectionLight = reflect(-dir, vNormal);
```
第二步,我们要根据相机位置计算视线与反射光线夹角的余弦,用到原理是向量的点乘。
```
vec3 eyeDirection = vCameraPos - vPos;
eyeDirection = normalize(eyeDirection);
// 与视线夹角余弦
float eyeCos = max(dot(eyeDirection, reflectionLight), 0.0);
```
第三步我们使用系数和指数函数设置镜面反射强度。指数越大镜面越聚焦高光的光斑范围就越小。这里我们指数取50.0系数取2.0。系数能改变反射亮度,系数越大,反射的亮度就越高。
```
float specular = 2.0 * pow(eyeCos, 50.0);
```
最后,我们将漫反射和镜面反射结合起来,就会让距离光源近的物体上,形成光斑。
```
// 合成颜色
gl_FragColor.rgb = specular + (ambientLight + diffuse) * materialReflection;
gl_FragColor.a = 1.0;
```
![](https://static001.geekbang.org/resource/image/36/09/36db1c0828a5a6e6aa1c4747431cee09.gif)
上面的代码是以点光源为例来实现的光斑其实只要是有向光都可以用同样的方法求出镜面反射只不过对应的入射光方向计算有所不同也就是着色器代码中的dir变量计算方式不一样。 你可以利用我上节课讲的内容,自己动手试试。
## 如何实现完整的Phong反射模型?
那在自然界中,除了环境光以外,其他每种光源在空间中都可以存在不止一个,而且因为几何体材质不同,物体表面也可能既出现漫反射,又出现镜面反射。
可能出现的情况这么多,分析和计算起来也会非常复杂。为了方便处理,我们可以把多种光源和不同材质结合起来,形成标准的反射模型,这一模型被称为[Phong反射模型](https://en.wikipedia.org/wiki/Phong_reflection_model)。
Phong反射模型的完整公式如下
$$
I\_{\\mathrm{p}}=k\_{\\mathrm{a}} i\_{\\mathrm{a}}+\\sum\_{m \\in \\text { lights }}\\left(k\_{\\mathrm{d}}\\left(\\hat{L}\_{m} \\cdot \\hat{N}\\right) i\_{m, \\mathrm{d}}+k\_{\\mathrm{s}}\\left(\\hat{R}\_{m} \\cdot \\hat{V}\\right)^{\\alpha} i\_{m, \\mathrm{s}}\\right)
$$
公式里的$k\_{\\mathrm{a}}$、$k\_{\\mathrm{d}}$和$k\_{\\mathrm{s}}$分别对应环境反射系数、漫反射系数和镜面反射系数。$\\hat{L}\_{m}$是入射光,$N$是法向量,$\\hat{R}\_{m}$是反射光,$V$是视线向量。$i$是强度,漫反射和镜面反射的强度可考虑因为距离的衰减。$$是和物体材质有关的常量,决定了镜面高光的范围。
根据上面的公式我们把多个光照的计算结果相加就能得到光照下几何体的最终颜色了。不过这里的Phong反射模型实际上是真实物理世界光照的简化模型因为它只考虑光源的光作用于物体没有考虑各个物体之间的反射光。所以我们最终实现出的效果也只是自然界效果的一种近似不过这种近似也高度符合真实情况了。
在一般的图形库或者图形框架中会提供符合Phong反射模型的物体材质比如ThreeJS中就支持各种光源和反射材质。
下面我们来实现一下完整的Phong反射模型。它可以帮助你对这个模型有更深入的理解让你以后使用ThreeJS等其他图形库也能够更加得心应手。整个过程分为三步定义光源模型、定义几何体材质和实现着色器。
### 1\. 定义光源模型
我们先来定义光源模型对象。环境光比较特殊我们将它单独抽象出来放在一个ambientLight的属性中而其他的光源一共有5个属性与材质无关我列了一张表放在了下面。
![](https://static001.geekbang.org/resource/image/88/d2/88ec1e9768fa4047964b19f8fc3d7fd2.jpg)
这样我们就可以定义一个Phong类。这个类由一个环境光属性和其他三种光源的集合组合而成表示一个可以添加和删除光源的对象。它的主要作用是添加和删除光源并把光源的属性通过uniforms访问器属性转换成对应的uniform变量主要的代码如下
```
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);
}
}
}
removeLight(light) {
if(this.directionalLights.has(light)) this.directionalLights.delete(light);
else if(this.pointLights.has(light)) this.pointLights.delete(light);
else if(this.spotLights.has(light)) this.spotLights.delete(light);
}
get uniforms() {
const MAX_LIGHT_COUNT = 16; // 最多每种光源设置16个
this._lightData = this._lightData || {};
const lightData = this._lightData;
lightData.directionalLightDirection = lightData.directionalLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.directionalLightColor = lightData.directionalLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.pointLightPosition = lightData.pointLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.pointLightColor = lightData.pointLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.pointLightDecay = lightData.pointLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.spotLightDirection = lightData.spotLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.spotLightPosition = lightData.spotLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.spotLightColor = lightData.spotLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.spotLightDecay = lightData.spotLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
lightData.spotLightAngle = lightData.spotLightAngle || {value: new Float32Array(MAX_LIGHT_COUNT)};
[...this.directionalLights].forEach((light, idx) => {
lightData.directionalLightDirection.value.set(light.direction, idx * 3);
lightData.directionalLightColor.value.set(light.color, idx * 3);
});
[...this.pointLights].forEach((light, idx) => {
lightData.pointLightPosition.value.set(light.position, idx * 3);
lightData.pointLightColor.value.set(light.color, idx * 3);
lightData.pointLightDecay.value.set(light.decay, idx * 3);
});
[...this.spotLights].forEach((light, idx) => {
lightData.spotLightPosition.value.set(light.position, idx * 3);
lightData.spotLightColor.value.set(light.color, idx * 3);
lightData.spotLightDecay.value.set(light.decay, idx * 3);
lightData.spotLightDirection.value.set(light.direction, idx * 3);
lightData.spotLightAngle.value[idx] = light.angle;
});
return {
ambientLight: {value: this.ambientLight},
...lightData,
};
}
}
```
有了这个类之后,我们就可以创建并添加各种光源了。我在下面的代码中,添加了一个平行光和两个点光源,你可以看看。
```
const phong = new Phong();
// 添加一个平行光
phong.addLight({
direction: [-1, 0, 0],
});
// 添加两个点光源
phong.addLight({
position: [-3, 3, 0],
color: [1, 0, 0],
});
phong.addLight({
position: [3, 3, 0],
color: [0, 0, 1],
});
```
### 2\. 定义几何体材质
定义完光源之后,我们还需要定义几何体的**材质**material因为几何体材质决定了光反射的性质。
在前面的课程里我们已经了解了一种与几何体材质有关的变量即物体的反射率MaterialReflection。那在前面计算镜面反射的公式float specular = 2.0 \* pow(eyeCos, 50.0);中也有两个常量2.0和50.0把它们也提取出来我们就能得到两个新的变量。其中2.0对应specularFactor表示镜面反射强度50.0指的是shininess表示镜面反射的光洁度。
这样我们就有了3个与材质有关的变量分别是matrialReflection 材质反射率、specularFactor 镜面反射强度、以及shininess (镜面反射光洁度)。
然后我们可以创建一个Matrial类来定义物体的材质。与光源类相比这个类非常简单只是设置这三个参数并通过uniforms访问器属性获得它的uniform数据结构形式。
```
class Material {
constructor(reflection, specularFactor = 0, shininess = 50) {
this.reflection = reflection;
this.specularFactor = specularFactor;
this.shininess = shininess;
}
get uniforms() {
return {
materialReflection: {value: this.reflection},
specularFactor: {value: this.specularFactor},
shininess: {value: this.shininess},
};
}
}
```
那么我们就可以创建matrial对象了。这里我一共创建4个matrial对象分别对应要显示的四个几何体的材质。
```
const matrial1 = new Material(new Color('#0000ff'), 2.0);
const matrial2 = new Material(new Color('#ff00ff'), 2.0);
const matrial3 = new Material(new Color('#008000'), 2.0);
const matrial4 = new Material(new Color('#ff0000'), 2.0);
```
有了phong对象和matrial对象我们就可以给几何体创建WebGL程序了。那我们就使用上面四个WebGL程序来创建真正的几何体网格并将它们渲染出来吧。具体代码如下
```
const program1 = new Program(gl, {
vertex,
fragment,
uniforms: {
...matrial1.uniforms,
...phong.uniforms,
},
});
const program2 = new Program(gl, {
vertex,
fragment,
uniforms: {
...matrial2.uniforms,
...phong.uniforms,
},
});
const program3 = new Program(gl, {
vertex,
fragment,
uniforms: {
...matrial3.uniforms,
...phong.uniforms,
},
});
const program4 = new Program(gl, {
vertex,
fragment,
uniforms: {
...matrial4.uniforms,
...phong.uniforms,
},
});
```
### 3\. 实现着色器
接下来我们重点看一下支持phong反射模型的片元着色器代码是怎么实现的。这个着色器代码比较复杂我们一段一段来看。
首先我们来看光照相关的uniform变量的声明。这里我们声明了vec3和float数组数组的大小为16。这样对于每一种光源我们都可以支持16个。
```
#define MAX_LIGHT_COUNT 16
uniform mat4 viewMatrix;
uniform vec3 ambientLight;
uniform vec3 directionalLightDirection[MAX_LIGHT_COUNT];
uniform vec3 directionalLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightPosition[MAX_LIGHT_COUNT];
uniform vec3 pointLightDecay[MAX_LIGHT_COUNT];
uniform vec3 spotLightColor[MAX_LIGHT_COUNT];
uniform vec3 spotLightDirection[MAX_LIGHT_COUNT];
uniform vec3 spotLightPosition[MAX_LIGHT_COUNT];
uniform vec3 spotLightDecay[MAX_LIGHT_COUNT];
uniform float spotLightAngle[MAX_LIGHT_COUNT];
uniform vec3 materialReflection;
uniform float shininess;
uniform float specularFactor;
```
接下来我们实现计算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);
}
// 处理点光源
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
vec3 decay = pointLightDecay[i];
if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
vec3 dir = (viewMatrix * vec4(pointLightPosition[i], 1.0)).xyz - pos;
float dis = length(dir);
dir = normalize(dir);
float cos = max(dot(dir, normal), 0.0);
float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
diffuse += d * cos * pointLightColor[i];
specular += getSpecular(dir, normal, eye);
}
// 处理聚光灯
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
vec3 decay = spotLightDecay[i];
if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
vec3 dir = (viewMatrix * vec4(spotLightPosition[i], 1.0)).xyz - pos;
float dis = length(dir);
dir = normalize(dir);
// 聚光灯的朝向
vec3 spotDir = (viewMatrix * vec4(spotLightDirection[i], 0.0)).xyz;
// 通过余弦值判断夹角范围
float ang = cos(spotLightAngle[i]);
float r = step(ang, dot(dir, normalize(-spotDir)));
float cos = max(dot(dir, normal), 0.0);
float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
diffuse += r * d * cos * spotLightColor[i];
specular += r * getSpecular(dir, normal, eye);
}
return vec4(diffuse, specular);
}
```
最后我们在main函数中调用phongReflection函数来合成颜色。代码如下
```
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;
}
```
最终呈现的视觉效果如下图所示:
![](https://static001.geekbang.org/resource/image/36/09/36db1c0828a5a6e6aa1c4747431cee09.gif)
你注意一下上图右侧的球体。因为我们一共设置了3个光源一个平行光、两个点光源它们都能够产生镜面反射。所以这些光源叠加在一起后这个球体就呈现出3个镜面高光。
## Phong反射模型的局限性
虽然phong反射模型已经比较接近于真实的物理模型不过它仍然是真实模型的一种近似。因为它没有考虑物体反射光对其他物体的影响也没有考虑物体对光线遮挡产生的阴影。
当然,我们可以完善这个模型。比如,将物体本身反射光(主要是镜面反射光)对其他物体的影响纳入到模型中。另外,我们也要考虑物体的阴影。当我们把这些因素更多地考虑进去的时候,我们的模型就会更加接近真实世界的物理模型。
当我们渲染3D图形的时候要呈现越接近真实的效果往往要考虑更多的参数因此所需的计算量也越大那我们就需要有更强的渲染能力比如更好的显卡更快的CPU和GPU并且也需要我们尽可能地优化计算的性能。
但是有很多时候我们需要在细节和性能上做出平衡和取舍。那性能优化的部分也是我们课程的重点我会在性能篇详细来讲。这节课我们就重点关注反射模型总结出完整的Phong反射模型就可以了。
## 要点总结
今天我们把环境光、平行光、点光源、聚光灯这四种光源整合并且在上节课讲的漫反射的基础上添加了镜面反射形成了完整的Phong反射模型。在这里我们实现的着色器代码能够结合四种光源的效果除了环境光外每种光源还可以设置多个。
在Phong反射模型中光照在物体上的最终效果由各个光源的性质参数和物体的表面材质共同决定。
Phong反射模型也只是真实世界的一种近似因为我们并没有考虑物体之间反射光的相互影响也没有考虑光线的遮挡。如果把这些因素考虑进去那我们的模型可以更接近真实世界了。
## 小试牛刀
我们知道,平行光、点光源和聚光灯是三种常见的方向光,但真实世界还有其他的方向光,比如探照灯,它是一种有范围的平行光,类似于聚光灯,但又不完全一样。你能给物体实现探照灯效果吗?
这里我先把需要用到的参数告诉你包括光源方向searchLightDirection、光源半径searchLightRadius、光源位置searchLightPosition、光照颜色searchLightColor。你可以用OGL实现探照灯效果然后把对应的着色器代码写在留言区。
![](https://static001.geekbang.org/resource/image/ce/6d/ce4bcyye0f4ac139625d96a2d5aeb06d.jpeg "探照灯示意图")
而且,探照灯的光照截面不一定是圆形,也可以是其他图形,比如三角形、菱形、正方形,你也可以试着让它支持不同的光照截面。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
* * *
## 源码
课程中完整代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/lights)
## 推荐阅读
[Phong反射模型简介](https://en.wikipedia.org/wiki/Phong_reflection_mode)