WebGL学习(七)光照

858 阅读13分钟

1.光影

image.png 即使是一个纯白色的立方体,我们也能知道它是立体的。这是因为他有明暗关系。

所以光照是三维图像很重要的组成。

2. 光照类型

这里我们只讨论最简单常见的三种光源。

image.png

平行光:就是现实生活中,无穷远处的光,就像太阳,因为他在很远的地方,我们可以近似它射出的光线是平行的。

点光源:相当于现实生活中的电灯,因为距离比较近我们可以看做是辐射状的光线

环境光:就是周围环境的光,比如在一个红色房间里环境光就是红色

3. 反射类型

光线照到物体后会发生一系列反射,这里主要有两种。 漫反射:光线以一定角度照射在物体上后反射的光线。

image.png

如果物体表面光滑,就像镜子,那么反射光就会以固定的角度反射出去,而现实中大部分物体表面都是粗糙的,所以反射角都不是固定的。

环境反射:周围环境反射回来的光线

image.png

4. 平行光的漫反射

我们从简单的光照开始研究如何计算出正确的色彩。平行光的特点是,在同一平面上每一条光线与平面的夹角都是一样的。

image.png

我们先根据现实生活中的情况总结一下反射光和什么有关系:

  1. 入射角θ。当我们把光源放置在表面正上方时,也就是θ = 0°时反射的光线最亮,当我们平视表面或者站在平面背面看θ > 90°时我们应该啥也看不到,一片漆黑。注意这里的入射角指的是光线方向和平面朝向也就是法线的夹角。
  2. 光线颜色和物体基底颜色。假如物体是白色,照射红色的光,那么反射回来的也应该是红色。

我们先给出一个直觉的公式:

<漫反射光颜色>=<入射光颜色><表面基底颜色>cosθ<漫反射光颜色> = <入射光颜色> * <表面基底颜色> * cos\theta

这里的入射光颜色和表面基底颜色是对应分量相乘(因为他们是矢量不是矩阵),可以参考之前的文章

根据上面公式我们验证一下,假如入射光线颜色为(1, 1, 1)白色,表面基底(1, 0, 0)红色,当我们垂直平面照射的时候cosθ = 1,根据公式,反射光应该是(1*1*1, 1*0*1, 1*0*1) = (1,0,0)最后应该是红色,这是符合直觉。

4.1 获取入射角

根据上面的图例,要知道入射角,我们需要知道平面法向量光线照到平面的角度。但是很遗憾,我们不知道每一束光线在平面上是什么角度。我们可以借用向量的点积算出夹角。

ab=a×b×cosθa \cdot b = |a| \times |b| \times cos\theta

反射光的颜色和法向量长度、光线长度没有关系,所以我们直接归一化向量,让他们变成单位向量,只保留方向信息,这样他们的模变成了1,最后他们的点积就等于他们的夹角余弦。

此时的公式变成

<漫反射光颜色>=<入射光颜色><表面基底颜色>(<入射光方向><法线向量>)<漫反射光颜色> = \\<入射光颜色> * <表面基底颜色> * \quad (<入射光方向> \cdot <法线向量>)

image.png 这里还有一点要注意,入射点指向光源才是光线方向。因为两个矢量夹角的定义是起点相同。

4.2 获取法向量

平面的法向量有两个特点:

  1. 一个平面有两个法向量,一个在正面,一个在背面。

image.png 2. 法向量的在同一平面上是唯一的。

image.png

法向量的计算方法就是初中高中几何学的哪几种:

  • 如果平面的方程是 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. 逐顶点数据

image.png 立方体每个面的法向量n如图所示,这是归一化的。

由于一个顶点相邻三个面,所以一个顶点对应了三个法向量。

image.png

就像之前画单一颜色立方体的方法那样,给每个顶点单独设置颜色。

我们也可以用这种方法,存储平面的法向量(因为同一平面的任一点的法向量都是一样的)。

4.3. 代码实现

理论准备好了,就可以实践了,先来看效果,基于上一节的代码改造

image.png 光线大概就是图中箭头位置

原本的立方体是这样

image.png

加了点变化可以看到不同位置光线的效果

msedge_UIK71iYygP.gif

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更方便。

image.png

5. 加入环境光

上面代码有一个很明显的缺点,比如,光源正射立方体右侧右侧会最亮,其他面会变暗,但实际上其他面变成了全黑,因为此时的光线和其他面的法向量夹角为90°/180°

现实生活中,除了照射的光源,环境也有自己的颜色,你在蓝色的房子里看白色的方块和在红色房子里看肯定是不一样的。

image.png

计算环境光的公式很简单:

<环境光反射颜色>=<表面基底颜色><环境光颜色><表面反射光颜色>=<漫反射光颜色>+<环境反射光颜色><环境光反射颜色> = <表面基底颜色> * <环境光颜色> \\ <表面反射光颜色> = <漫反射光颜色> + <环境反射光颜色>

我们不用考虑环境光是什么角度照射的,就认为是均匀的照射在物体上。

同样我们用现实来验证一下:

假如表面是白色(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])

image.png 可以看到其他几面没有全黑

6. 运动物体的光照

一个物体运动起来计算光照,思路很简单,就是重新计算各个表面的法向量,就能得到新的光照了。

6.1. 计算运动后的法向量

image.png 一个物体可能平移、旋转、拉伸:

  • 平移不会改变法向量
  • 旋转会改变
  • 拉伸会改变

你可能想用某个公式去计算,旋转还好,但是拉伸就很复杂,不同的拉伸程度法向量改变的方向都不一样。

所以需要借助特殊的数学工具,逆转置矩阵

这个原理推导自行查阅,大概意思就是两个垂直的向量缩放之后还是垂直,旋转其实也可以看成某一个方向缩放几次。

使用逆转置矩阵计算变换之后的法向量方法是:

M:变换矩阵M:变换矩阵的逆转置矩阵n:变换之前的法向量变换之后的法向量=MnM:变换矩阵\\ M':变换矩阵的逆转置矩阵\\ n:变换之前的法向量\\ 变换之后的法向量 = M' * n

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)
// ... 省略

image.png 我只是旋转了立方体,绕z轴旋转了-45°,调整了下观察位置(视图矩阵),让我们可以看见两个面(红色和绿色面),光线方向还是从右侧正射物体,很符合直觉,正对右侧的两个面被照亮了。

我们看看不使用法线变换的样子

image.png

被照亮的还是绿色那一面,因为法线方向都没变,所以即使物体变换了,但是颜色依然没变。

7. 点光源

点光源不同于平行光,每一个顶点的光线角度是不一样的。

image.png

怎么计算当前顶点的入射光向量呢?这里其实就是高中知识,已知两个点求直线方向向量。只需要将两个点相减就行了。

当然还需要考虑物体变换的时候,物体变换之后它相对于世界的坐标就会发生变化,所以计算的时候要算出在当前光照下的顶点世界坐标。

世界坐标和你在哪里观察它没有关系。

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)

image.png

msedge_nOS0ZLkIeT.gif

看着更加逼真。

7.2. 缺点

image.png 在这张图中可以很明显看到分界线,这样是因为我们是根据每一个顶点来计算的光照(逐顶点的),然而我们一个表面也就计算了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代码不变

对比我用现成的,这里使用的两个球体。

1683857851268.jpg

1683857851279.jpg

上面的逐片元的效果,下面是逐顶点的效果。