当光线打到一个物体上时,靠近光源的地方会显示物体原本的颜色的亮色,当物体比较光滑时还会出现高光,距光源越远就会越暗,直至变的一片漆黑,当然现实生活中物体背光面并不会是一片漆黑,光线还会在空间中的其他物体上进行多次反射,间接的打到物体的背光面。
如何在程序中模拟光照呢?玩游戏的可能听说过光线追踪,但是光线追踪太过复杂,并且需要进行大量计算(想玩光追游戏还得有个高端显卡:)),所以一般运用在离线渲染中。本文介绍的是一种简化过后的模型 —— Blinn-Phong 反射模型。
Blinn-Phong 反射模型最早由 Phong 提出,后由 Blinn 对其做出改进。该模型不但计算效率高并且和物理事实足够接近,可以得到不错的效果。
下面会使用一些向量简写,L 表示光照向量,N 表示法线向量,E 表示观察向量,H 表示半角向量。
现实世界中一个物体没有直接光线照射的地方我们也能看见。这是因为有很多光线通过墙壁或地面等物体的经过多次反射最终打到物体的背光面,然后从背光面反射到我们的眼镜中,这才让我们可以看见它。
要准确的计算一个点的环境光是非常困难且复杂的,Phong 反射模型提出了一个简化的方式,可以快速计算出一个点的环境光。它假设一个点的接收的环境光永远都是相同的是一个常量,可以用下方公式表示。
ambient = ambientColor * ambientLight
一个物体的环境光反射就等于它的环境光系数乘以来自环境的光。
当光线打到一个粗糙的表面时光线会被散射到各个方向,一般反射到各个方向上的光线强度并不像。
Phong 反射模型假设光线被均匀的反射到各个方向的强度都相同,这样无论什么方向观察物体的亮度都一样。
相同光以不同的角度照射到物体表面时,表面接收到的能量会不一样,比如中午时太阳光直射到地面,地面接收到的能量就特别多,旁晚时太阳光斜射到地面,地面接收的能量也就越少。根据 Lambert 定律可以知道,物体表面的接收的能量和表面的法线与光线的夹角有关。
energy = L · N = cos(Theta)
可以表示成单位光线向量与单位法线的点乘。因为两个都是单位向量,所以最终的结果就等于 cos(光线与法线的夹角)
。
表面的能量不仅与夹角有关,还与光源到物体之间的距离有关,距离越远接收到的能量也就越少。一般使用一个二次函数表示能量的衰减,因为这样更能准确模拟现实生活中的光的衰减。
Attenuation = 1 / (a + b * d + c * d^2)
其中 d
是距离,a
b
c
应该取什么值,可以参考文章末尾文献 5,可以用来实现比较真实的效果。
最终漫反射的公式,如下
diffuse = (1 / (a + b * d + c * d^2)) * max(L · N, 0) * diffuseColor * diffuseLight
上面公式表示物体的漫反射与光到达物体表面的能量和表面接收的能量,物体的材质和光线的属性有关。它将上面提到的两个公式直接相乘,其中使用了 max
函数,是因为如果点乘为负数时表示光线从下方打到物体表面。
当我们以一定角度看一个比较光滑的物体时,可以看见物体表面的高光。这是因为观察方向和镜面反射方向非常的接近。
参考漫反射,我们可以通过镜面反射向量与观察向量之间的夹角来计算物体的高光,Phong 反射模型就是这样计算的。
Blinn 就在这里提出了计算高光的优化,他提出了半角向量,通过半角向量我们可以很轻松的计算物体的高光。这就是为什么称作 Blinn-Phong 反射模型。
半角向量等于光线向量加上观察向量。
通过半角向量与法线的夹角来计算物体的高光,如果我们观察的方向与镜面反射光的夹角越接近,那么半角向量与法线之间的夹角也就越小。
specular = (1 / (a + b * d + c * d^2)) * pow(max(N · H, 0), shininess) * specularColor * specularLight
同样我们加上距离的衰减,这里还进行求幂运算,这是因为现实生活中物体的高光一般都很小,观察方向与反射方向夹角稍微一大就看不见高光。
最终我们将这三个颜色加起来就是物体最终现实的颜色了。
color = ambient + diffuse + specular
了解了怎么计算后,我们有三种方式来应用它。
平面着色顾名思义就是对物体的每个面应用光照计算,也就是物体的一个面将应用同一种颜色,一般情况平面着色计算量比较小,但是效果也会最差。
Gourand 着色是对物体的每个顶点进行着色。法线是与面垂直的,但是如何计算一个点的法线呢?
首先我们需要找到这个顶点周围所有连接的面,然后将这些面的法线进行平均运算,这样就得到了这个顶点的法线。
Phong 着色是对每个像素进行计算,也就是先求出各个顶点的法线,然后通过这些法线进行插值,求出这个面上每个像素的法线。
一般情况下效果如上图。效果一个比一个好,但是计算量一个比一个大。为什么说一般情况下是这样呢?因为如果面非常小时,甚至比一个像素还要小,那么平面着色也可以得到不错的效果。
了解了原理,那么怎么运用到实际应用中呢?接下来就将上面的知识运用到 WebGL 中吧。看看实际的情况!
为了简洁,下面只展示关键代码,完整代码请参考文章末尾源码。
首先为了更细致的控制光照我们定义下面两个结构体。
struct Material { // 物体的材质
vec3 ambient;
vec3 diffuse;
vec3 specular;
float shininess; // 用来限制高光大小
};
struct Light { // 灯光
vec4 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
// 下面是距离衰减的 3 个参数
float constant;
float linear;
float quadratic;
};
点光源像一个灯泡,它有固定的位置,向四面八方发射光线。
attribute vec4 aPos;
attribute vec3 aNormal;
uniform Light light;
uniform Material material;
uniform mat4 modelMat;
uniform mat4 viewMat;
uniform mat4 projMat;
uniform vec3 camera;
varying vec3 vColor;
void main() {
vec3 normal = normalize(aNormal);
vec3 pos = (viewMat * modelMat * aPos).xyz;
vec3 ambient = light.ambient * material.ambient; // 环境光
vec3 lightDir = normalize(light.position.xyz - pos); // 光照向量
vec3 diffuse = max(dot(normal, lightDir), 0.) * light.diffuse * material.diffuse; // 漫反射
vec3 h = normalize(lightDir + normalize(camera - pos)); // 半角向量
vec3 specular = pow(max(dot(normal, h), 0.), material.shininess) * light.specular * material.specular; // 高光
float distance = length(light.position.xyz - pos);
float attenuation = 1. / (light.constant + light.linear * distance + light.quadratic * (distance * distance)); // 距离衰减
vColor = (ambient + diffuse + specular) * attenuation;
gl_Position = projMat * vec4(pos, 1.);
}
上面展示了如何顶点着色器中计算光照,也就是上面的讲的 Gourand 着色。由于在着色器中我们获取不到其他顶点,所以一般都是使用 Gourand 或 Phong 着色。如果想使用 Flat 着色只有在 CPU 里面进行计算,然后传给着色器。
实现了计算光照的顶点着色器,还需要渲染的物体。
function createBufferInfo(gl, program, attr, data) {
const a = gl.getAttribLocation(program, attr)
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
return { buffer, attr: a }
}
const superShape = createSuperShape() // 返回上篇文章的 SuperShape
const posInfo = createBufferInfo(gl, program, 'aPos', superShape);
const normalInfo = createBufferInfo(gl, program, 'aNormal', superShape)
// 传入顶点和法线数据
上面我们并没有计算每个顶点的法线,而是直接把顶点位置设置成法线。这是因为 SuperShape 是通过标准球方式生成的,而球顶点的法线就等于它的位置。我们可以想象一个球在原点,原点发射出一条条经过各个顶点的射线,这个射线就是顶点的法线,它是和顶点重合的。
如果我们运行代码就会发现光源也跟着模型跑。这是因为物体的顶点坐标变了但是它的法线没变,我们可以将法线也乘上 Model 矩阵,但是这样物体在做非等比缩放时还会出现问题。
正确的做法是求 Model 矩阵的逆转置矩阵,至于为什么要求逆转置矩阵及数学推导可以看这篇文章。
由于求逆转置矩阵计算量比较大,所以我们不在着色器中计算,而是在外面算好传进去。
const normalMat = mat4.transpose(mat4.create(), mat4.invert([], modelMat));
// 在着色器中
vec3 normal = normalize(mat3(normalMat) * aNormal);
这里使用数学库 glMatrix 来进行矩阵计算,然后在顶点着色器中将法线乘上这个矩阵。
平行光是把点光源放在无限远的地方,这时物体上的各个点的光线方向都相同,对我们来说太阳光就是平行光。
要把点光源变成平行光非常简单,我们只需要把光源位置向量的 w
设置成 0
就行了。在齐次坐标中 w
等于 0
代表是向量,否则代表是位置。
vec3 lightDir = normalize(light.position.w > 0. ? light.position.xyz - pos : light.position.xyz);
然后修改一下计算光线方向的代码,如果 w
大于 0
说明是点光源,让它减去当前位置求出光线方向,否则光线方向就是光源位置,也就是平行光。
这样我们只用修改参数就可以对光源进行切换,无需修改着色器代码。
聚光灯其实就是限制了照射范围的点光源。
我们需要知道聚光的方向和当前光源照射到点的位置,然后求出它们之间的夹角,来判断是否超出了聚光灯的照射范围,当超出了聚光灯照射的角度,我们就不进行漫反射计算。
怎么求出聚光的方向和当前光到点的方向的角度呢?我们对两个向量进行点乘,两个单位向量之间的点乘就等于它们之间的余弦。
struct Light {
vec4 position;
vec3 direction; // 当前聚光方向
vec3 ambient;
vec3 diffuse;
vec3 specular;
float cutOff; // 当前聚光照射范围
float constant;
float linear;
float quadratic;
};
我们首先在 Light
上新加两个属性。
之前都是在顶点着色器中计算也就是 Gourand 着色,这次在片元着色器中计算吧,也就是使用 Phong 着色。
attribute vec4 aPos;
attribute vec3 aNormal;
uniform mat4 modelMat;
uniform mat4 viewMat;
uniform mat4 projMat;
uniform mat4 normalMat;
varying vec4 vPos;
varying vec3 vNormal;
void main() {
vPos = modelMat * aPos;
vNormal = mat3(normalMat) * aNormal;
gl_Position = projMat * viewMat * vPos;
}
顶点着色器输出当前顶点位置和法线,然后这两个值就会被插值到各个片元上。
void main() {
vec3 normal = normalize(vNormal);
vec3 pos = vPos.xyz;
vec3 ambient = light.ambient * material.ambient;
vec3 surfaceToLight = normalize(light.position.xyz - pos);
vec3 diffuse = vec3(0);
vec3 specular = vec3(0); // 默认没有漫反射和高光
if (dot(normalize(light.direction), surfaceToLight) >= light.cutOff) { // 当前这个片元在照射范围内才计算漫反射和高光
vec3 lightDir = normalize(light.position.w > 0. ? light.position.xyz - pos : light.position.xyz);
diffuse = max(dot(normal, lightDir), 0.) * light.diffuse * material.diffuse;
vec3 h = normalize(lightDir + normalize(camera - pos));
specular = pow(max(dot(normal, h), 0.), material.shininess) * light.specular * material.specular;
}
float distance = length(light.position.xyz - pos);
float attenuation = 1. / (light.constant + light.linear * distance + light.quadratic * (distance * distance));
gl_FragColor = vec4((ambient + diffuse + specular) * attenuation, 1.);
}
上面计算出了当前这个片元的光照颜色,默认情况下只有环境光,只有当前这个像素在光照范围内才计算它的漫反射和高光。
getAndSetUniform(gl, program, 'camera', [0, 0, 10])
getAndSetUniform(gl, program, 'light.position', [0, 0, 10, 1])
getAndSetUniform(gl, program, 'light.direction', [0, 0, 10]) // 由于物体在原点,所以光源位置就是光源方向
getAndSetUniform(gl, program, 'light.cutOff', Math.cos(rad(3))) // 照射范围是 3 度,随便选的
下图是最终的渲染结果。
效果并不理想,光照边缘过渡太硬了,有锯齿,我们想要一个渐变效果,让边缘平滑过渡。
我们可以再加个外边缘,对内边缘到外边缘之间进行光照强度插值。
struct Light {
float cutOff;
float outerCutOff;
};
我们再在 Light
上新增一个 outerCutOff
。
float theta = dot(normalize(light.direction), surfaceToLight); // 求出当前点的光照角度
float intensity = smoothstep(light.outerCutOff, light.cutOff, theta); // 进行强度插值
vec3 lightDir = normalize(lightPos.w > 0. ? lightPos.xyz - pos : lightPos.xyz);
vec3 diffuse = max(dot(normal, lightDir), 0.) * light.diffuse * material.diffuse;
vec3 h = normalize(lightDir + normalize(camera - pos));
vec3 specular = pow(max(dot(normal, h), 0.), material.shininess) * light.specular * material.specular;
diffuse *= intensity;
specular *= intensity; // 乘以强度
我们这里使用 glsl 中的内建 smoothstep
函数,进行插值,它会返回 0 到 1 之间的结果。
上图是 cutOff
为 1 度,outerCutOff
为 3 度的结果。
漫反射贴图与上面的十分类似,唯一的区别是将纹理映射到 3D 模型光照的漫反射项上。
首先在网上找一张地球的平面图。
然后只需修改片元作色器的一行代码。3D 模型数据使用上篇文章中提到的标准球。
uniform sampler2D uTex;
void main() {
vec3 normal = normalize(vNormal);
vec3 pos = vPos.xyz;
vec4 lightPos = light.position;
vec3 ambient = light.ambient * material.ambient;
vec3 surfaceToLight = normalize(lightPos.xyz - pos);
vec3 lightDir = normalize(lightPos.w > 0. ? lightPos.xyz - pos : lightPos.xyz);
vec3 diffuse = max(dot(normal, lightDir), 0.) * light.diffuse * texture2D(uTex, vTex).rgb;
// 这里使用 texture2D 函数获取纹理对应的颜色
vec3 h = normalize(lightDir + normalize(camera - pos));
vec3 specular = pow(max(dot(normal, h), 0.), material.shininess) * light.specular * material.specular;
float distance = length(lightPos.xyz - pos);
float attenuation = 1. / (light.constant + light.linear * distance + light.quadratic * (distance * distance));
gl_FragColor = vec4((ambient + diffuse + specular) * attenuation, 1.);
}
这里将原来计算漫反射项中的材质,替换成了纹理对应的颜色值。然后我们就得到了有光照的地球 3D 模型啦!
漫反射贴图,只要在这个模型之上稍微修改就可以得到不错的效果,当然还有镜面光贴图,可以控制物体表面高光的区域,与漫反射贴图不同它是通过纹理来控制高光,比如实现光打到有金属框的木头盒子上的效果。
本文介绍了 Blinn-Phong 反射模型,3 种着色方式,并在 WebGL 中使用 3 种灯光进行渲染。最后介绍了将这个模型结合纹理只需稍微的修改就可以实现非常不错的效果。
了解了 Blinn-Phong 反射模型,对我们学习其他的 3D 库或建模软件的灯光也很有帮助,比如下面这一段 Three.js 代码。
const material = new THREE.MeshPhongMaterial({
ambient: 0x555555,
color: 0x555555,
specular: 0xffffff,
shininess: 50
});
const light = new THREE.PointLight(0xff0040, 2, 0);
light.position.set(200, 100, 300);
相信看了这篇文章,就算没有用过 Three.js 也知道这段代码的作用。
在线预览:https://codepen.io/woopen/full/PoNjVNe
源码:https://github.com/woopen/Blinn-Phong
1、《WebGL 编程指南》
2、《交互式计算机图形学》
3、WebGL Fundamentals: https://webglfundamentals.org/
4、LearnOpenGL: https://learnopengl-cn.github.io/
5、http://wiki.ogre3d.org/tiki-index.php?page=-Point+Light+Attenuation
全文完
以下文章您可能也会感兴趣:
从对称加密到非对称加密再到认证中心 -- https 的证书申请
文字描述符了解一下
简单聊聊 TCP 的可靠性
一篇文章带你搞懂 Swagger 与 SpringBoot 整合
延时队列:基于 Redis 的实现
你真的懂 Builder 设计模式吗?论如何实现真正安全的 Builder 模式
锁优化的简单思路
iOS开发:Archive、ipa 和 App 包瘦身
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 [email protected] 。