1.光影
即使是一个纯白色的立方体,我们也能知道它是立体的。这是因为他有明暗关系。
所以光照是三维图像很重要的组成。
2. 光照类型
这里我们只讨论最简单常见的三种光源。
平行光:就是现实生活中,无穷远处的光,就像太阳,因为他在很远的地方,我们可以近似它射出的光线是平行的。
点光源:相当于现实生活中的电灯,因为距离比较近我们可以看做是辐射状的光线
环境光:就是周围环境的光,比如在一个红色房间里环境光就是红色
3. 反射类型
光线照到物体后会发生一系列反射,这里主要有两种。 漫反射:光线以一定角度照射在物体上后反射的光线。
如果物体表面光滑,就像镜子,那么反射光就会以固定的角度反射出去,而现实中大部分物体表面都是粗糙的,所以反射角都不是固定的。
环境反射:周围环境反射回来的光线
4. 平行光的漫反射
我们从简单的光照开始研究如何计算出正确的色彩。平行光的特点是,在同一平面上每一条光线与平面的夹角都是一样的。
我们先根据现实生活中的情况总结一下反射光和什么有关系:
- 入射角
θ。当我们把光源放置在表面正上方时,也就是θ = 0°时反射的光线最亮,当我们平视表面或者站在平面背面看θ > 90°时我们应该啥也看不到,一片漆黑。注意这里的入射角指的是光线方向和平面朝向也就是法线的夹角。 - 光线颜色和物体基底颜色。假如物体是白色,照射红色的光,那么反射回来的也应该是红色。
我们先给出一个直觉的公式:
这里的入射光颜色和表面基底颜色是对应分量相乘(因为他们是矢量不是矩阵),可以参考之前的文章。
根据上面公式我们验证一下,假如入射光线颜色为(1, 1, 1)白色,表面基底(1, 0, 0)红色,当我们垂直平面照射的时候cosθ = 1,根据公式,反射光应该是(1*1*1, 1*0*1, 1*0*1) = (1,0,0)最后应该是红色,这是符合直觉。
4.1 获取入射角
根据上面的图例,要知道入射角,我们需要知道平面法向量和光线照到平面的角度。但是很遗憾,我们不知道每一束光线在平面上是什么角度。我们可以借用向量的点积算出夹角。
反射光的颜色和法向量长度、光线长度没有关系,所以我们直接归一化向量,让他们变成单位向量,只保留方向信息,这样他们的模变成了1,最后他们的点积就等于他们的夹角余弦。
此时的公式变成
这里还有一点要注意,入射点指向光源才是光线方向。因为两个矢量夹角的定义是起点相同。
4.2 获取法向量
平面的法向量有两个特点:
- 一个平面有两个法向量,一个在正面,一个在背面。
2. 法向量的在同一平面上是唯一的。
法向量的计算方法就是初中高中几何学的哪几种:
- 如果平面的方程是 ax+by+cz=d 的形式,那么向量 (a,b,c) 就是平面的法向量。
- 如果平面上有三个不共线的点 P,Q,R,那么向量 PQ×PR 就是平面的法向量,其中×表示叉乘。
- 如果平面是由参数方程 x(s,t) 表示的曲面,那么向量 x_s×x_t 就是平面的法向量,其中 x_s 和 x_t 分别表示对 s 和 t 的偏导数,×表示叉乘。
这里我们就用简单的立方体来实现光照,复杂的图像不深究,代码怎么计算的可以参考这里
由于正方体每个面都是垂直的,所以法向量特别简单。
4.2.1. 逐顶点数据
立方体每个面的法向量
n如图所示,这是归一化的。
由于一个顶点相邻三个面,所以一个顶点对应了三个法向量。
就像之前画单一颜色立方体的方法那样,给每个顶点单独设置颜色。
我们也可以用这种方法,存储平面的法向量(因为同一平面的任一点的法向量都是一样的)。
4.3. 代码实现
理论准备好了,就可以实践了,先来看效果,基于上一节的代码改造
光线大概就是图中箭头位置
原本的立方体是这样
加了点变化可以看到不同位置光线的效果
4.3.1. 完整代码
下面来解析代码
// 顶点着色器
// mvp矩阵
uniform mat4 viewMat;
// 顶点位置
attribute vec4 pos;
// 顶点颜色
attribute vec4 color;
// 法线向量
attribute vec4 normal;
// 光源信息不需要变化,不需要做插值
// 所以定义为uniform
// 光源颜色
uniform vec3 lightColor;
// 光源方向
uniform vec3 lightDirection;
varying vec4 _color;
void main(){
// 绘制立方体顶点
gl_Position = viewMat * pos;
gl_PointSize = 10.0;
// 计算反射颜色
// 颜色只需要三个维度就行了,把alpha加进来没什么意义
// max和dot是gls自带的,如果点积小于0就设置为0
float cos = max(dot(lightDirection, normal.xyz), 0.0);
// 这个就是前面的公式
vec3 diffuse = lightColor * color.rgb * cos;
// 应用颜色
_color = vec4(diffuse, color.a);
}
// 片元着色器
precision mediump float;
varying vec4 _color;
void main(){
gl_FragColor = _color;
}
//...初始化代码
// v6----- v5
// /| /|
// v1------v0|
// | | | |
// | |v7---|-|v4
// |/ |/
// v2------v3
// 在前面那节的基础上增加一个缓冲区
// 法向量缓冲区
const normal = gl.getAttribLocation(program, 'normal')
var normalBuffer = gl.createBuffer();
var normals = new Float32Array([
0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // v0-v1-v2-v3 front
1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, // v0-v3-v4-v5 right
0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, // v0-v5-v6-v1 up
-1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, // v1-v6-v7-v2 left
0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, // v7-v4-v3-v2 down
0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0 // v4-v7-v6-v5 back
]);
// 绑定法线向量缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer)
gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW)
gl.vertexAttribPointer(normal, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(normal)
// 设置光线
const lightColor = gl.getUniformLocation(program, 'lightColor')
const lightDirection = gl.getUniformLocation(program, 'lightDirection')
gl.uniform3fv(lightColor, [1, 1, 1])
// 归一化光线方向
let lightPos = [0.5, 1.5, 0.3]
const normalizedLightDirection = vec3.normalize(vec3.create(), vec3.fromValues.apply(null, lightPos))
gl.uniform3fv(lightDirection, normalizedLightDirection)
// ... 省略绘制代码
4.3.2. 顶点着色器代码
float cos = max(dot(lightDirection, normal.xyz), 0.0);
计算夹角的时候,发现向量没有归一化,因为传入的时候已经归一化了,但是最好在着色器中也归一化一下。
float cos = max(dot(lightDirection, normalize(normal.xyz)), 0.0);
而光线方向lightDirection没有在着色器中归一化,因为在js代码中已经归一化了。
这里还需要注意,选择在js还是着色器里面归一化是需要考虑的。
对于这里的法向量,由于着色器本身就要遍历顶点数据,所以每一个顶点都需要计算,因此放在着色器里归一化是不影响性能的。
而对于光线方向,在一次绘制中它是固定的,没必要每次计算顶点的时候都归一化一次,所以在从js传入之前就可以归一化。
此外,这段代码还有一点值得注意,这里使用了max函数做了一个限制,这样限制的原因是,夹角的余弦小于0证明夹角大于90°,也就是从平面的背面照了过来,这样不需要多余的计算,直接赋值为0更方便。
5. 加入环境光
上面代码有一个很明显的缺点,比如,光源正射立方体右侧右侧会最亮,其他面会变暗,但实际上其他面变成了全黑,因为此时的光线和其他面的法向量夹角为90°/180°。
现实生活中,除了照射的光源,环境也有自己的颜色,你在蓝色的房子里看白色的方块和在红色房子里看肯定是不一样的。
计算环境光的公式很简单:
我们不用考虑环境光是什么角度照射的,就认为是均匀的照射在物体上。
同样我们用现实来验证一下:
假如表面是白色(1,1,1)入射光是淡蓝色(0, 0, 0.2)那么环境反射光是(0, 0, 0.2),最后物体表面就是淡蓝色。
假如表面是红色(1, 0, 0),入射光是弱白光(0.2,0.2,0.2),那么环境反射光是(0.2, 0, 0)弱红色。
5.1 代码实现
// 顶点着色器
// mvp矩阵
uniform mat4 viewMat;
// 顶点位置
attribute vec4 pos;
// 顶点颜色
attribute vec4 color;
// 法线向量
attribute vec4 normal;
// 光源颜色
uniform vec3 lightColor;
// 光源方向
uniform vec3 lightDirection;
// 环境光颜色
uniform vec3 ambientColor;
varying vec4 _color;
void main(){
// 绘制立方体顶点
gl_Position = viewMat * pos;
gl_PointSize = 10.0;
// 计算反射颜色
float cos = max(dot(lightDirection, normalize(normal.xyz)), 0.0);
vec3 diffuse = lightColor * color.rgb * cos;
// 计算环境反射光 多了一步计算环境反射光
vec3 ambient = ambientColor * color.rgb;
// 应用颜色 加上漫反射
_color = vec4(diffuse + ambient, color.a);
}
// 片元着色器
precision mediump float;
varying vec4 _color;
void main(){
gl_FragColor = _color;
}
// js部分和之前是一样的,只是多了一个设置环境光颜色
// 这里我们设置成一个暗暗的光线
const ambientColor = gl.getUniformLocation(program, 'ambientColor')
gl.uniform3fv(ambientColor, [0.2, 0.2, 0.2])
可以看到其他几面没有全黑
6. 运动物体的光照
一个物体运动起来计算光照,思路很简单,就是重新计算各个表面的法向量,就能得到新的光照了。
6.1. 计算运动后的法向量
一个物体可能平移、旋转、拉伸:
- 平移不会改变法向量
- 旋转会改变
- 拉伸会改变
你可能想用某个公式去计算,旋转还好,但是拉伸就很复杂,不同的拉伸程度法向量改变的方向都不一样。
所以需要借助特殊的数学工具,逆转置矩阵。
这个原理推导自行查阅,大概意思就是两个垂直的向量缩放之后还是垂直,旋转其实也可以看成某一个方向缩放几次。
使用逆转置矩阵计算变换之后的法向量方法是:
6.2. 代码实现
// 顶点着色器
// .. 省略代码
// 初始位置法线向量
attribute vec4 originalNormal;
// 法向量变换矩阵 也就是逆转置矩阵
uniform mat4 normalMatrix;
varying vec4 _color;
void main(){
// ..省略代码
// 计算变换后的法向量
// 这里直接用vec3截取了前三个分量
vec3 normal = normalize(vec3(normalMatrix * originalNormal));
// 计算反射颜色
float cos = max(dot(lightDirection, normal), 0.0);
vec3 diffuse = lightColor * color.rgb * cos;
// ..省略代码
}
// 片元着色器
precision mediump float;
varying vec4 _color;
void main(){
gl_FragColor = _color;
}
// js代码只是增加了变换矩阵相关的代码
// ... 省略
const rotate = -45 * Math.PI / 180
const normalMatrix = gl.getUniformLocation(program, 'normalMatrix')
// 旋转四元数,绕z轴旋转rotate弧度
const routateQuat = quat.create()
quat.rotateZ(routateQuat, routateQuat, rotate)
// 生成model矩阵
const modelMat = mat4.fromRotationTranslation(mat4.create(), routateQuat, [0,0,0])
// 透视空间矩阵
const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
// 视图矩阵
const _viewMat = mat4.lookAt(mat4.create(), [5, 2, 7], [0, 0, 0], [0, 1, 0])
// 计算mvp矩阵
let mvpMat = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
mvpMat = mat4.multiply(mat4.create(), mvpMat, modelMat)
// modelMat的逆转置
gl.uniformMatrix4fv(normalMatrix, false, mat4.transpose(mat4.create(), mat4.invert(mat4.create(), modelMat)))
gl.uniformMatrix4fv(viewMat, false, mvpMat)
// ... 省略
我只是旋转了立方体,绕
z轴旋转了-45°,调整了下观察位置(视图矩阵),让我们可以看见两个面(红色和绿色面),光线方向还是从右侧正射物体,很符合直觉,正对右侧的两个面被照亮了。
我们看看不使用法线变换的样子
被照亮的还是绿色那一面,因为法线方向都没变,所以即使物体变换了,但是颜色依然没变。
7. 点光源
点光源不同于平行光,每一个顶点的光线角度是不一样的。
怎么计算当前顶点的入射光向量呢?这里其实就是高中知识,已知两个点求直线方向向量。只需要将两个点相减就行了。
当然还需要考虑物体变换的时候,物体变换之后它相对于世界的坐标就会发生变化,所以计算的时候要算出在当前光照下的顶点世界坐标。
世界坐标和你在哪里观察它没有关系。
7.1. 代码实现
// 顶点着色器
// mvp矩阵
uniform mat4 mvpMat;
// model矩阵
uniform mat4 modelMat;
// 顶点位置
attribute vec4 pos;
// 顶点颜色
attribute vec4 color;
// 初始位置法线向量
attribute vec4 originalNormal;
// 法向量变换矩阵 也就是逆转置矩阵
uniform mat4 normalMat;
// 光源颜色
uniform vec3 lightColor;
// 光源方向
uniform vec3 lightDirection;
// 环境光颜色
uniform vec3 ambientColor;
varying vec4 _color;
void main(){
// 绘制立方体顶点
gl_Position = mvpMat * pos;
gl_PointSize = 10.0;
/************************ 增加下面代码 计算光线方向******************************/
// 计算顶点世界坐标
vec4 vertexPosition = modelMat * pos;
// 计算该顶点的光线方向
vec3 vertexLightDirection = normalize(lightDirection - vec3(vertexPosition));
/******************************************************/
// 计算变换后的法向量
vec3 normal = normalize(vec3(normalMat * originalNormal));
// 计算反射颜色
float cos = max(dot(vertexLightDirection, normal), 0.0);
vec3 diffuse = lightColor * color.rgb * cos;
// 计算环境反射光
vec3 ambient = ambientColor * color.rgb;
// 应用颜色
_color = vec4(diffuse + ambient, color.a);
}
// 片元着色器
precision mediump float;
varying vec4 _color;
void main(){
gl_FragColor = _color;
}
// js代码就改了几处
// 设置光线方向
let lightPos = [ 2, 2, 2]
// 不再需要归一化坐标了,
const lightDirection = gl.getUniformLocation(program, 'lightDirection')因为需要计算出方向向量
gl.uniform3fv(lightDirection, lightPos)
// ...
// 增加一个保存model矩阵的调用
gl.uniformMatrix4fv(modelMatrix, false, modelMat)
看着更加逼真。
7.2. 缺点
在这张图中可以很明显看到分界线,这样是因为我们是根据每一个顶点来计算的光照(逐顶点的),然而我们一个表面也就计算了4个顶点,片元都是通过内插出来的,所以颜色并不是很精确。如果我们对表面的每一个点都计算一次(根据每个片元计算),那么就会更加逼真
8. 逐片元光照
思路很简单,就是把之前关于光照的计算从顶点着色器移动到片元着色器,这样我们计算的就是逐片元的。
// 顶点着色器
// mvp矩阵
uniform mat4 mvpMat;
// model矩阵
uniform mat4 modelMat;
// 顶点位置
attribute vec4 pos;
// 顶点颜色
attribute vec4 color;
// 初始位置法线向量
attribute vec4 originalNormal;
varying vec4 _color;
varying vec4 _originalNormal;
varying vec4 _vertexPosition;
void main(){
// 绘制立方体顶点
gl_Position = mvpMat * pos;
gl_PointSize = 10.0;
// 计算顶点世界坐标
_vertexPosition = modelMat * pos;
/****************** 光线计算移动到片元着色器 *******************/
// // 计算该顶点的光线方向
// vec3 vertexLightDirection = normalize(lightDirection - vec3(vertexPosition));
// // 计算变换后的法向量
// vec3 normal = normalize(vec3(normalMat * originalNormal));
// // 计算反射颜色
// float cos = max(dot(vertexLightDirection, normal), 0.0);
// vec3 diffuse = lightColor * color.rgb * cos;
// // 计算环境反射光
// vec3 ambient = ambientColor * color.rgb;
// // 应用颜色
// _color = vec4(diffuse + ambient, color.a);
_color = color;
// 法向量是存储在顶点缓冲区的,所以只能间接传递给片元着色器
_originalNormal = originalNormal;
}
// 片元着色器
precision mediump float;
// 光源颜色
uniform vec3 lightColor;
// 光源方向
uniform vec3 lightDirection;
// 环境光颜色
uniform vec3 ambientColor;
// 法向量变换矩阵 也就是逆转置矩阵
uniform mat4 normalMat;
varying vec4 _color;
varying vec4 _originalNormal;
varying vec4 _vertexPosition;
void main(){
// 计算该顶点的光线方向
vec3 vertexLightDirection = normalize(lightDirection - vec3(_vertexPosition));
// 计算变换后的法向量
vec3 normal = normalize(vec3(normalMat * _originalNormal));
// 计算反射颜色
float cos = max(dot(vertexLightDirection, normal), 0.0);
vec3 diffuse = lightColor * _color.rgb * cos;
// 计算环境反射光
vec3 ambient = ambientColor * _color.rgb;
gl_FragColor = vec4(diffuse + ambient, _color.a);
}
// js代码不变
对比我用现成的,这里使用的两个球体。
上面的逐片元的效果,下面是逐顶点的效果。