记一个立方体的产生

从原生WebGL的角度分析一个立方体的产生。例子虽简单,但包含了好多基础概念哈哈,算是入门阶段的小小总结吧。

首先来看看最终的效果 (♥◠‿◠)ノ:


WebGL坐标系统

与Canvas画布的坐标系不同,WebGL默认采用右手坐标系:

WebGL坐标原点在画布中点。

以将屏幕上某点的px坐标转换到WebGL坐标为例:

1
2
3
4
5
6
7
let x = e.clientX,
y = e.clientY;
let rect = e.target.getBoundingClientRect();
x = ((x - rect.left) - canvas.width/2)/(canvas.width/2);
y = ((y - rect.height) - canvas.height/2)/(canvas.height/2);

先是将px坐标转换为canvas画布坐标(即减去画布与屏幕左和上的距离),再转换成WebGL坐标(由于坐标值为0.0-1.0的值,所以要进行比值操作)

缓冲区对象

用于临时保存多个顶点信息,一次性绘制多个顶点。

创建缓冲区对象 -> 绑定缓冲区对象 -> 写入数据到缓冲区对象 -> 分配缓冲区对象给attribute变量 -> 开启attribute变量

如将正方体的每个顶点坐标,颜色值,法向量坐标输送到顶点着色器变量里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const vertices = new Float32Array([
1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0,-1.0, 1.0, 1.0,-1.0, 1.0, // v0-v1-v2-v3 front
1.0, 1.0, 1.0, 1.0,-1.0, 1.0, 1.0,-1.0,-1.0, 1.0, 1.0,-1.0, // v0-v3-v4-v5 right
1.0, 1.0, 1.0, 1.0, 1.0,-1.0, -1.0, 1.0,-1.0, -1.0, 1.0, 1.0, // v0-v5-v6-v1 up
-1.0, 1.0, 1.0, -1.0, 1.0,-1.0, -1.0,-1.0,-1.0, -1.0,-1.0, 1.0, // v1-v6-v7-v2 left
-1.0,-1.0,-1.0, 1.0,-1.0,-1.0, 1.0,-1.0, 1.0, -1.0,-1.0, 1.0, // v7-v4-v3-v2 down
1.0,-1.0,-1.0, -1.0,-1.0,-1.0, -1.0, 1.0,-1.0, 1.0, 1.0,-1.0 // v4-v7-v6-v5 back
]);
let buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
gl.bufferData(gl.ARRAY_BUFFER,vertices,gl.STATIC_DRAW);
let a_Attribute = gl.getAttribLocation(gl.program,'a_Postion');
gl.vertexAttribPointer(a_Attribute,num,type,false,0,0);
gl.enableVertexAttribArray(a_Attribute);

变换(模型矩阵)

将物体进行多种变换,实际是多种矩阵做乘法。这里用封装好的矩阵API来创建模型变换矩阵。

正方体在不断的旋转:

1
2
3
4
let modelMatrix = new Matrix4();
let currentAngle = animate(currentAngle);
modelMatrix.setRotate(currentAngle, 0, 1, 0);
gl.uniformMatrix4fv(u_ModelMatrix,false,modelMatrix.elements);

观察者视点和视线

通过指定观察点位置,目标坐标,上方向,生成视图矩阵。

如这里我们看正方体:

1
2
let viewMatrix = new Matrix4();
viewMatrix.setLookAt(4, 3, 7, 0, 0, 0, 0, 1, 0);

可视空间(投影矩阵)

和现实中的照相机一样,可视区间是有一定范围的,在范围之外的,可以不用渲染,来节约开销。

两类可视空间:

  1. 正射投影
  2. 透视投影

正射投影中,不管目标离视点多远,呈现的大小就是原本的大小,这适合打印等场景,要模拟显示场景,就需要深度感,即目标大小离视点越远,看起来应该越小。

创建透视投影矩阵

我们这里的正方体模拟显示场景,用透视投影,
透视投影图示:

创建透视投影矩阵,需指定垂直视角(顶面与底面夹角,图中两条红色线夹角),近剪裁面宽高比,近剪裁面和远剪裁面的位置。为了防止正方体变形,将宽高比设为画布宽高比:

1
2
let projMatrix = new Matrix4();
projMatrix.setPerspective(30,canvas.width/canvas.height,1,100);

得到最终位置

实际绘制在画布上的顶点位置,是经过原本坐标矩阵,模型矩阵,视图矩阵,投影矩阵运算而来的。

即最终

1
gl_Position = u_MvpMatrix * a_Position;

其中u_MvpMatrix表示模型视图投影综合矩阵。

如这个正方体:

1
2
3
let mvpMatrix = new Matrix4();
mvpMatrix.set(projMatrix).multiply(viewMatrix).multiply(modelMatrix);
gl.uniformMatrix4fv(u_MvpMatrix,false,mvpMatrix.elements);

光照

可以看到,这个正方体是模拟在光照环境下的,而且是点光源光。

漫反射光

对于平行光和点光源,漫反射形式的反射光颜色最终取决于入色光颜色,表面本身颜色,以及入射光与表面法线夹角决定。

即:

1
<漫反射光颜色> = <入射光颜色> x <表面基底色> x cosa

根据数量积公式,得到

1
cosa = <光线方向单位向量>.<法线方向单位向量>

对于点光源,还需要通过点光源位置和目标点位置计算出光线方向

1
2
3
4
vec3 normal = normalize(v_Normal);
vec3 lightDirection = normalize(u_LightPosition - v_Position);
float nDotL = max(dot(lightDirection,normal),0.0);
vec3 diffuse = u_LightColor * vec3(v_Color) * nDotL; //漫反射光颜色

加上环境光效果

最终的物体视觉颜色应该是:

1
2
vec3 ambient = u_AmbientLight * v_Color.rgb;
gl_FragColor = vec4(diffuse + ambient,v_Color.a);

转动的正方体光照

目标在不断转动,会导致顶点位置在不断变化,使得光线方向在不断变化,而且转动会导致顶点的法向量发生变化。

对于光线方向,在计算的时候,目标顶点坐标就应该是经过模型矩阵运算后的坐标(不包括视图,投影,因为光照考虑实际目标位置),即

1
v_Position = vec3(u_ModelMatrix * a_Position);

对于变化的法向量:

1
变化后的法向量 = 原来的法向量矩阵 x 模型矩阵的逆转置矩阵。

如这里的正方体:

1
2
3
4
let normalMatrix = new Matrix4();
normalMatrix.setInverseOf(modelMatrix);
normalMatrix.transpose();
gl.uniformMatrix4fv(u_NormalMatrix,false,normalMatrix.elements);

着色器:

1
v_Normal = normalize(vec3(u_NormalMatrix * a_Normal));

其他细节

隐藏面消除

默认情况下,WebGL是以顶点在缓冲区的顺序进行绘制的,当我们绘制的各目标实际远近顺序和数据在缓冲区顺序不一致时,就会出现远处目标本来看不到的部分挡住了近处的目标现象。

开启隐藏面消除功能:

1
gl.enable(gl.DEPTH_TEST);

绘制之前,清除深度缓冲区:

1
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

顶点索引

你可能会问,上面的顶点坐标类型数组中,为什么有24个顶点数据呢?
这是因为在正方体中,每个顶点可能处于不同面,不同面有不同法向量,webGL提供drawElements方法,通过索引方式绘制,这样做可以节省一些数据(不然需要6x6=36个顶点)

1
2
3
4
5
6
7
8
9
10
11
12
13
const indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // right
8, 9,10, 8,10,11, // up
12,13,14, 12,14,15, // left
16,17,18, 16,18,19, // down
20,21,22, 20,22,23 // back
]);
let indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indices,gl.STATIC_DRAW);
...
gl.drawElements(gl.TRIANGLES,24,gl.UNSIGNED_BYTE,0);

逐顶点不自然现象

如果在顶点着色器中进行逐顶点的颜色计算,会发现最后立方体表面颜色渐变不够自然。这是因为靠几个顶点进行内插计算出来的效果和实际光照效果有一定差别,这就需要我们进行逐片元的颜色计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const FSHADER_SOURCE = `
precision mediump float;
uniform vec3 u_LightColor;
uniform vec3 u_LightPosition;
uniform vec3 u_AmbientLight;
varying vec4 v_Color;
varying vec3 v_Normal;
varying vec3 v_Position;
void main(){
vec3 normal = normalize(v_Normal);
vec3 lightDirection = normalize(u_LightPosition - v_Position);
float nDotL = max(dot(lightDirection,normal),0.0);
vec3 diffuse = u_LightColor * vec3(v_Color) * nDotL;
vec3 ambient = u_AmbientLight * v_Color.rgb;
gl_FragColor = vec4(diffuse + ambient,v_Color.a);
}
`

精度限定

着色器对片元着色器的float类型没有默认精度,需要手动添加(不然会报错):

1
2
3
precision mediump float;//中精度
precision highp float; //高精度
precision lowp float; //低精度

渲染过程

  1. 对缓冲区对象的每个顶点,执行顶点着色器,将顶点坐标赋值给gl_Position后,顶点进入图形装配区域,暂存。

  2. 所有点传入后,根据绘制API第一个参数决定如何装配图形,然后装配出图形。

  3. 对装配出来的几何图形进行光栅化,对装配的几何图形用像素进行填充。

  4. 对光栅化后的图形进行逐片元调用片元着色器,片元着色器会根据片元坐标计算每个片元的信息,比如颜色,纹理像素等并写入颜色缓冲区。如这里的正方体,我们就在片元着色器里计算像素颜色值(在光照影响下),如果同一个表面的四个顶点颜色不同,会发现有一个渐变效果,这便是片元着色器进行插值计算的结果。

  5. 当所有片元的值计算完毕后,进行最终渲染。

initShaders()

这里绘制正方体参照《WebGL编程指南》并用了代码示例中的一些库文件,包括这里的initShaders():

  1. 创建着色器对象(gl.createShader());
  2. 向着色器对象中填充着色器的源代码(gl.shaderSource());
  3. 编译着色器(gl.compileProgram());
  4. 创建程序对象(gl.createProgram());
  5. 为程序对象分配着色器(gl.attachShader());
  6. 连接程序对象(gl.linkProgram());
  7. 使用程序对象(gl.useProgram());

详细细节参考《WebGL编程指南》

结束语

可以看到,用原生WebGL API进行绘制是多么的麻烦,这里还引入了一些矩阵库,简化了矩阵转换细节。虽说麻烦,但是对于用来学习图形学基础知识还是不错的。

罗峡的博客 wechat
欢迎扫描上面的微信公众号二维码,关注我的个人公众号:全栈前端
坚持原创技术分享,您的支持将鼓励我继续创作!