网易乐得技术团队

使用webGL绘图(一)

webGL图形渲染流水线

以下是我对webgl渲染流水线的理解,只是选取了对理解渲染流程最有帮助的部分,并没有介绍所有流程。因水平有限,难免会有错误之处,欢迎指正!

webGL绘图的流程好比工厂里的流水线,step by step,最终产出成品。

渲染管线

第一步,JavaScript传入数据给webGL系统,数据会是以下两种:

  1. 顶点数据,比如顶点坐标、纹理坐标、顶点颜色、顶点法向量等等;
  2. 与顶点无关的数据,比如矩阵、采样器、光源位置向量等等;

第二步,顶点着色器(vertex shader)执行,它是一段使用glsl es语言编写的小程序片段,做了以下事情:

  1. 接收来自JavaScript传入的顶点数据(attribute变量)、与顶点无关的数据(uniform变量);
  2. 对接收到的数据进行加工处理,然后将最终计算的顶点坐标赋值给内置的gl_Position变量;
  3. 将需要的变量传给片元着色器(varying变量)

第三步,图元装配,简单的说就是将顶点着色器最终计算的顶点坐标连线,组成点、线、面等几何图元。具体是画哪种图元是通过JavaScript传入的图元信息决定的。webGL能画的图元只有点、直线段、三角形三种,图元装配环节就是将复杂的图形分割成一个个的点、直线段、三角形。

第四步,光栅化,图元装配出来的几何图形被打散成一个个的像素,这个过程叫光栅化。光栅化后的像素才会被着色。

第五步,片元着色器(fragment shader)执行,和顶点着色器一样,也是一段glsl es语言编写的小程序片段。它会将光栅化后的像素逐个着色,这个过程在颜色缓冲区中进行:

  1. 接收来自顶点着色器传过来的顶点数据(比如顶点坐标、纹理坐标、顶点颜色、顶点法向量等);
  2. 对接收到的顶点数据进行线性插值,这样顶点数据就演变为像素数据(比如逐个像素坐标、逐个像素纹理坐标、逐个像素颜色、逐个像素法向量等);
  3. 接收来自JavaScript传入的与顶点无关的数据(uniform变量)
  4. 对接收到的数据进行处理,将最终计算的片元(像素)颜色赋值给内置的gl_FragColor变量。

…(为了突出重点,这里省略了很多步骤)

最后一步,刷新颜色缓冲区,此时,浏览器canvas画布就显示了图像。

整个图形流水线,其中开发人员最关心的应该是顶点着色器和片元着色器,因为它们是可编程的。

顶点着色器和片元着色器简介

webGL的着色器是用GLSL ES语言写的,它的语法很像C。下面分别举个栗子来介绍顶点着色器和片元着色器的程序结构和要点。因水平有限,难免会有错误之处,欢迎指正!

顶点着色器

一个栗子:

1
2
3
4
5
6
7
attribute vec2 position, uv;
uniform mat4 projection, model;
varying vec2 v_uv;
void main() {
gl_Position = projection * model * vec4(position, 0.0, 1.0);
v_uv = uv;
}

顶点着色器代码都有一个入口main函数,是程序开始执行的地方。顶点着色器甭管多复杂,它只有两个目的:

  • 给gl_Position这个内置变量赋值,即将最终计算出来的顶点位置信息传给webGL系统;
  • 给片元着色器传值。

attribute、uniform、varying是放到变量前面的修饰符:

  • attribute表示顶点数据。比如顶点坐标、纹理坐标:

    1
    attribute vec2 position, uv;
  • uniform表示与顶点无关数据,或者说对所有的顶点都一样的数据。比如投影矩阵、模型矩阵:

    1
    uniform mat4 projection, model;
  • varying表示传给片元着色器的数据,比如纹理坐标:

    1
    varying vec2 v_uv;

vec2、mat4表示变量的类型,vec2表示二维向量、mat4表示四维矩阵。

上面的栗子的含义是:根据传入的顶点坐标、视图矩阵、模型矩阵,计算出最终的顶点坐标,然后将纹理坐标传给片元着色器。

片元着色器

一个栗子:

1
2
3
4
5
6
precision mediump float;
uniform sampler2D texture;
varying vec2 v_uv;
void main() {
gl_FragColor = texture2D(texture, v_uv);
}

和顶点着色器一样,片元着色器也有一个入口main函数。片元着色器只有一个目的,就是逐像素计算颜色并赋值给内置的gl_FragColor变量。

片元着色器必须要带上对浮点数的精度限定,不然会报错。原因是片元着色器没有对float类型变量的默认精度限定,所以要手动指定:

1
precision mediump float;

可选的精度有三种:highp(高精度)、mediump(中等精度)、lowp(低精度)。

sampler2D类型是2D纹理专用类型,除此之外,还支持立方体纹理samplerCube。它们都是与顶点无关的,所以使用uniform修饰。

上面的栗子的含义是:根据JavaScript传入的纹理采样器(纹理图)和纹理坐标,逐片元抽取纹素(纹理颜色)给像素着色。

着色器总结

对于顶点着色器,传入多少个顶点它就要执行多少次;对于片元着色器,光栅化多少个像素它就要执行多少次。可以看出,着色器的执行是非常频繁的,这就要求很高的效率。所以着色器代码都不是在CPU里面运行的,而是在GPU里运行的。同时,在写着色器代码的时候,也应该力求短小精干。

VBO & VAO

开发webGL图形,需要综合使用JavaScript和GLSL ES这两种语言,缺一不可。JavaScript负责传递数据给GLSL ES,GLSL ES负责在GPU中绘图。

下面介绍一下JavaScript是如何传递数据的。因水平有限,难免会有错误之处,欢迎指正!

VBO和VAO这两个名词经常在openGL相关的文章中见到,个人理解应该是图形学的一种约定俗成的术语。

VBO

全称 vertex buffer object,从字面意义来看,vertex表示顶点,说明它跟顶点数据相关,buffer表示缓冲区。实际上,它表示存入了顶点数据的一块显存,并包装成了一个对象。VBO里的数据只是毫无意义的一维数组,只知道它表示顶点数据,并不知道它们是表示顶点坐标,还是纹理坐标,还是什么滴。如果表示顶点坐标,也不知道这一维数组中几个元素表示一个顶点坐标,可能是三个(x,y,z),也可能是两个(x,y)。

VAO

VBO只是显存中那混沌的数据,没有含义,只是方便着色器读取,因为都在显卡中,所以读取速度快。而VAO就像那盘古,用板斧将混沌的VBO劈开,于是就分清楚了顶点坐标、纹理坐标、顶点颜色等等。
VAO的全称 vertex array object,它定义了VBO中的数据表示什么,应该传给顶点着色器中的哪个变量,该如何取用这个数据。

一堆名词解释很难看懂,还是举个栗子吧,就来一段代码:

顶点着色器:

1
2
3
4
5
6
7
attribute vec2 position; // 顶点坐标
attribute vec3 color; // 顶点颜色
varying vec3 v_color; // 传给片元着色器的顶点颜色
void main() {
gl_Position = vec4(position, 0.0, 1.0);
v_color = color;
}

片元着色器:

1
2
3
4
5
precision mediump float; // 精度限定符
varying vec3 v_color; // 从顶点着色器传来并进行了线性插值
void main() {
gl_FragColor = vec4(v_color, 1.0); // 给当前像素附颜色值
}

JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import glNow from 'gl-now';
import createShader from 'gl-shader';
import createVBO from 'gl-buffer';
import createVAO from 'gl-vao';
import vShader from './shader.vs';
import fShader from './shader.fs';
import triangle from '../common/gemo/triangle';
import { arr2to1 } from '../common/tools';

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]), // 可以看作是canvas画布背景色
});

let shader, vao;

/**
* 初始化干两件数
* 1、编译着色器
* 2、使用vbo和vao在显存中存入顶点相关数据
*/

shell.on('gl-init', function() {
const gl = shell.gl; // 上下文

shader = createShader(gl, vShader, fShader); // 编译着色器并返回一个着色器对象
shader.attributes.position.location = 0; // 着色器中attribute变量在vao中的索引
shader.attributes.color.location = 1; // 着色器中attribute变量在vao中的索引

const positionVBO = createVBO(gl, arr2to1(triangle.positions)), // 顶点坐标数据存入显存
positionColorVBO = createVBO(gl, arr2to1(triangle.positionColors)); // 顶点颜色数据存入显存

vao = createVAO(gl, [
{ buffer: positionVBO, size: 2 },
{ buffer: positionColorVBO, size: 3 }
]);
});

/**
* 渲染循环干两件事
* 1、将数据和图元传给着色器
* 2、启动着色器渲染图形
*/

shell.on('gl-render', function() {
const gl = shell.gl; // 上下文
shader.bind(); // 绑定着色器,下面有关着色器的操作都是针对该着色器
vao.bind(); // 绑定vao,向attribute变量传值
vao.draw(gl.TRIANGLES, arr2to1(triangle.cells).length); // 启动着色器,并告知使用的图元
});

需要说明的一点是,我们不用原生的webGL API,而是使用一个叫做 stack.gl 的工具库来简化原生API那繁琐的操作。这样就不会迷失在细节里,更容易看到全貌。

首先看着色器代码吧:

  • 顶点着色器接收了顶点坐标和顶点颜色,顶点坐标参与计算后赋值给gl_Position,顶点颜色传给片元着色器;
  • 片元着色器接收顶点着色器传过来的顶点颜色,然后内部插值成像素的颜色,然后赋值给gl_FragColor。

然后看JavaScript代码:

首先引入一坨模块,基本都是stack.gl的工具函数,详细信息请参看 stack.gl文档

glNow 是为了让我们快速开始开发webGL,我们知道使用webGL需要获取上下文,需要设置canvas的尺寸分辨率,这些无聊的操作还是交给它吧。将初始化的工作放在 gl-init 事件里,将渲染循环的工作放到 gl-render 事件里。

初始化的一开始,我们拿到了gl上下文,并开始创建着色器对象:

1
2
3
shader = createShader(gl, vShader, fShader); // 编译着色器并返回一个着色器对象
shader.attributes.position.location = 0; // 着色器中attribute变量在vao中的索引
shader.attributes.color.location = 1; // 着色器中attribute变量在vao中的索引

紧接着,我们创建了两个VBO,它们分别是三角形的顶点坐标(positionVBO)、顶点颜色(positionColorVBO):

1
2
const positionVBO = createVBO(gl, arr2to1(triangle.positions)), // 顶点坐标数据存入显存
positionColorVBO = createVBO(gl, arr2to1(triangle.positionColors)); // 顶点颜色数据存入显存

此时对于webGL系统来说它们还只是存储在显存中无意义的混沌。

接着盘古用板斧劈开混沌,就成了VAO:

1
2
3
4
vao = createVAO(gl, [
{ buffer: positionVBO, size: 2 },
{ buffer: positionColorVBO, size: 3 }
]);

此时,webGL系统弄清了一切:

  • positionVBO表示顶点坐标,是传给顶点着色器的position变量的,数组中每两个元素表示一个顶点坐标(x,y坐标);
  • positionColorVBO表示顶点颜色,是传给顶点着色器的color变量的,数组中每三个元素表示一个颜色值(r,g,b通道);

到目前为止,数据已经提供给webGL系统了,得开始启动绘制命令了,请看 gl-render 事件的回调:

1
2
3
shader.bind(); // 绑定着色器,下面有关着色器的操作都是针对该着色器
vao.bind(); // 绑定vao,向attribute变量传值
vao.draw(gl.TRIANGLES, arr2to1(triangle.cells).length); // 启动着色器,并告知使用的图元

我们获取上下文后,绑定了着色器对象,绑定了VAO,然后再调用绘制命令。为什么要有这一系列的“绑定”呢?其实webGL是个巨大的状态机,我们要通过改变它的状态后,再启动绘制命令。可以这么理解,我们可以准备好几套shader和VAO,都在显卡里存好了,想要绘制什么只需要绑定相关的shader和VAO就可以快速绘制出来。

最后看看传入的三角形的数据,即triangle模块的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export default {
// 顶点坐标
positions: [
[0.0, 0.5],
[-0.5, -0.5],
[0.5, -0.5]
],
// 顶点索引
cells: [
[0, 1, 2]
],
// 顶点法向量
normals: [],
// 纹理坐标
uvs: [
[0.5, 1.0],
[0.0, 0.0],
[1.0, 0.0]
],
// 顶点颜色
positionColors: [
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0]
],
};

运行结果如下:

三角形

很容易发现一个问题,从顶点坐标看三角形不应该那么狭长,这是因为webGL的原始坐标是从右下角(-1, -1)到左上角(1, 1),图上展示的是移动设备竖屏的表现,纵坐标比横坐标长。

于是乎很不满意,首先,坐标限制在-1到1之间很不爽;其次,坐标会随着屏幕尺寸变形拉伸;最后,比如在移动设备上,横屏和竖屏表现会不一致,竖屏的三角形高瘦,横屏的三角形矮胖。如何解决这些个适配的问题,请看下面的介绍!

坐标和适配

关于坐标系和适配的介绍,因水平有限,难免会有错误之处,欢迎指正!

首先展示一下webGL的坐标系:

坐标系

屏幕上webGL坐标系x、y轴都是从-1到1,坐标系会随着屏幕的高矮胖瘦而被拉伸变形,解决这个问题的方法是使用投影矩阵,投影矩阵分正交投影矩阵和透视投影矩阵,2D图没有透视效果,所以使用正交投影矩阵。

正交投影

正交投影矩阵可以用于2D和3D场景,用于2D场景的意义就是要解决我们之前提到的坐标系拉伸的问题,请看下图:

投影

左侧是透视投影,右侧是正交投影。我们看右侧的正交投影,它就是通过top、bottom、left、right、near、far这6个参数,即上下左右远近,规范一个立体的盒子,盒子中的物体会平行投影到近截面上,盒子之外的物体投影不上去,也就不会显示了。刚刚2D场景坐标变形的问题,可以用正交投影解决掉,因为我们可以规范一个远近截面宽高和屏幕宽高一样的盒子,盒子近截面坐标系不再是从-1到1,而是根据屏幕的宽高来定的,即横坐标是从 -(屏幕宽度/2) 到 (屏幕宽度/2),纵坐标是从 -(屏幕高度/2) 到 (屏幕高度/2),然后将这样正确的坐标系再规范化到webGL从-1到1的坐标系中。(需要注意的是,webGL从-1到1的坐标系并没有变,变的只是顶点坐标)。

正交投影如何实现呢?有一个写好的矩阵库可以帮我们轻松实现,请看下面的完整代码:

顶点着色器:

1
2
3
4
5
6
7
8
attribute vec2 position; // 顶点坐标
attribute vec3 color; // 顶点颜色
uniform mat4 projection; // 投影矩阵
varying vec3 v_color; // 传给片元着色器的顶点颜色
void main() {
gl_Position = projection * vec4(position, 0.0, 1.0);
v_color = color;
}

片元着色器:

1
2
3
4
5
precision mediump float; // 精度限定符
varying vec3 v_color; // 从顶点着色器传来并进行了线性插值
void main() {
gl_FragColor = vec4(v_color, 1.0); // 给当前像素附颜色值
}

JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import glNow from 'gl-now';
import createShader from 'gl-shader';
import createVBO from 'gl-buffer';
import createVAO from 'gl-vao';
import vShader from './shader.vs';
import fShader from './shader.fs';
import triangle from '../common/gemo/triangle';
import { arr2to1, updateProjection } from '../common/tools';
import { mat4 } from 'gl-matrix'; // 矩阵库

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]), // 可以看作是canvas画布背景色
});

let shader, vao;
let projection = mat4.create();

/**
* 初始化干两件数
* 1、编译着色器
* 2、使用vbo和vao在显存中存入顶点相关数据
*/

shell.on('gl-init', function() {
const gl = shell.gl, // 上下文
positionVBO = createVBO(gl, arr2to1(triangle.positions)), // 顶点坐标数据存入显存
positionColorVBO = createVBO(gl, arr2to1(triangle.positionColors)); // 顶点颜色数据存入显存

gl.enable(gl.CULL_FACE);

shader = createShader(gl, vShader, fShader); // 编译着色器并返回一个着色器对象
shader.attributes.position.location = 0; // 着色器中attribute变量在vao中的索引
shader.attributes.color.location = 1; // 着色器中attribute变量在vao中的索引

vao = createVAO(gl, [
{ buffer: positionVBO, size: triangle.positions[0].length },
{ buffer: positionColorVBO, size: triangle.positionColors[0].length }
]);
});

/**
* 渲染循环干两件事
* 1、将数据和图元传给着色器
* 2、启动着色器渲染图形
*/

shell.on('gl-render', function() {
const gl = shell.gl; // 上下文
shader.bind(); // 绑定着色器,下面有关着色器的操作都是针对该着色器
shader.uniforms.projection = projection; // 将投影矩阵传给着色器

vao.bind(); // 绑定vao,向attribute变量传值
vao.draw(gl.TRIANGLES, arr2to1(triangle.cells).length); // 启动着色器,并告知使用的图元
vao.unbind(); // 解绑vao
});

shell.on('gl-resize', function() {
const gl = shell.gl;
updateProjection(projection, gl.drawingBufferWidth, gl.drawingBufferHeight);
});

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 更新投影矩阵
* @param {mat4} out 输出变量
* @param {number} w 宽
* @param {number} h 高
*/

export function updateProjection(out, w, h) {
const whRate = w / h,
width = 2,
height = width / whRate,
x = width / 2,
y = height / 2,
z = 1;
mat4.ortho(out, -x, x, -y, y, -z, z);
}

上面的代码出现了新东西,顶点着色器出现了一个四维矩阵变量,因是与顶点无关的,所以用uniform修饰:

1
uniform mat4 projection; // 投影矩阵

projection变量仅仅参与了顶点坐标的计算,得到正确的顶点坐标。

1
projection * vec4(position, 0.0, 1.0)

JavaScript中定义了一个projection变量表示投影矩阵,它被初始化为一个单位矩阵:

1
let projection = mat4.create();

然后在 gl-resize 回调中,来给它赋值,当浏览器视口改变时,都会调用:

1
updateProjection(projection, gl.drawingBufferWidth, gl.drawingBufferHeight);

可以参看上面关于updateProjection函数的定义,它调用了mat4.ortho函数,会返回一个正交投影矩阵,我们给它传的参数也是构建一个与屏幕宽高相同,远近为1(可任意)的视景体。有了正确的投影矩阵,接下来要传到顶点着色器。

需要注意的是,我们并没有通过VBO和VAO来传到着色器,VBO和VAO是用来传顶点数据的,而投影矩阵是一个矩阵,和顶点无关,我们绑定着色器后,直接传就行了,很简单:

1
2
shader.bind(); // 绑定着色器,下面有关着色器的操作都是针对该着色器
shader.uniforms.projection = projection; // 将投影矩阵传给着色器

最终,我们得到了一个无论视口怎么变换都不会变形的三角形:

正投影三角形

动画

介绍完投影矩阵,趁热打铁,再介绍另外一种矩阵,就是模型矩阵,跟投影矩阵一样,它也是仅仅改变顶点坐标,只是功能和含义不同。投影矩阵决定图形怎么映射到屏幕上,模型矩阵只管图形的位移、旋转与缩放,配合渲染循环,我们能使用模型矩阵让图形产生动画效果。

举个栗子就很清楚了:

顶点着色器:

1
2
3
4
5
attribute vec2 position;
uniform mat4 projection, model;
void main() {
gl_Position = projection * model * vec4(position, 0.0, 1.0);
}

片元着色器:

1
2
3
4
5
precision mediump float;
uniform vec3 color;
void main() {
gl_FragColor = vec4(color, 1.0);
}

JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import glNow from 'gl-now';
import { mat4 } from 'gl-matrix';
import createShader from 'gl-shader';
import createVBO from 'gl-buffer';
import createVAO from 'gl-vao';
import vShader from './shader.vs';
import fShader from './shader.fs';
import triangle from '../common/gemo/triangle';
import { arr2to1, updateProjection } from '../common/tools';

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
});

let shader, vao,
model = mat4.create(), // 模型矩阵
projection = mat4.create(); // 视图矩阵

const startTime = new Date();

shell.on('gl-init', function() {
const gl = shell.gl; // 上下文
initShader(gl); // 初始化着色器
initVAO(gl); // 初始化vao
});

shell.on('gl-render', function() {
const gl = shell.gl, // 上下文
time = new Date() - startTime; // 经过的毫秒数

draw(gl, time); // 绘制
});

shell.on('gl-resize', function() {
const gl = shell.gl;
updateProjection(projection, gl.drawingBufferWidth, gl.drawingBufferHeight);
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

function draw(gl, time) {
// 单一动画( 旋转 )
// mat4模块内置了rotate(旋转)、translate(位移)、sacle(缩放)方法
// const deg = Math.sin( time / 1e3 ) * Math.PI;
// mat4.identity(model);
// mat4.rotate(model, model, deg, new Float32Array([0.0, 0.0, 1.0]));

// 组合动画( 旋转 + 位移 ),注意矩阵乘的顺序
const deg = time % (2 * Math.PI);
const distance = Math.sin( time / 1e3 ) * 1;
mat4.identity(model);
mat4.translate(model, model, new Float32Array([distance, 0.0, 0.0]));
mat4.rotate(model, model, deg, new Float32Array([0.0, 0.0, 1.0]));

const color = new Float32Array([1.0, 0.0, 0.0]);

shader.bind(); // 绑定着色器
shader.uniforms = { projection, model, color }; // 给着色器uniform变量传值

vao.bind(); // 绑定vao
vao.draw(gl.TRIANGLES, arr2to1(triangle.cells).length); // 启动着色器
vao.unbind(); // 解绑vao
}

function initShader(gl) {
shader = createShader(gl, vShader, fShader);
shader.attributes.position.location = 0;
}

function initVAO(gl) {
const positionVBO = createVBO(gl, arr2to1(triangle.positions)),
cellVBO = createVBO(gl, arr2to1(triangle.cells), gl.ELEMENT_ARRAY_BUFFER);
vao = createVAO(gl, [
{
buffer: positionVBO,
size: 2,
},
], cellVBO);
}

首先顶点着色器多了一个模型矩阵,需要注意的是,模型矩阵和投影矩阵都要乘以顶点坐标,这就涉及到顺序的问题。

1
projection * model * vec4(position, 0.0, 1.0)

显而易见的逻辑是,图形先运动过了,然后再投影到屏幕上,即先要进行模型变换,再进行投影变换。所以改变顶点坐标的顺序是,先 model * position,然后再 projection * ( model * position )。矩阵乘法不满足交换律,但满足结合律,。于是可以这样:( projection * model ) * position,也可以这样: projection * model * position。

在JavaScript代码中,用了一个变量model表示模型矩阵,在渲染循环中,不断改变model的值产生动画:

1
2
3
4
5
6
7
8
9
10
11
12
// 单一动画( 旋转 )
// mat4模块内置了rotate(旋转)、translate(位移)、sacle(缩放)方法
// const deg = Math.sin( time / 1e3 ) * Math.PI;
// mat4.identity(model);
// mat4.rotate(model, model, deg, new Float32Array([0.0, 0.0, 1.0]));

// 组合动画( 旋转 + 位移 ),注意矩阵乘的顺序
const deg = time % (2 * Math.PI);
const distance = Math.sin( time / 1e3 ) * 1;
mat4.identity(model);
mat4.translate(model, model, new Float32Array([distance, 0.0, 0.0]));
mat4.rotate(model, model, deg, new Float32Array([0.0, 0.0, 1.0]));

纹理贴图

下面介绍一下webGL纹理贴图。关于纹理贴图的介绍,因水平有限,难免会有错误之处,欢迎指正!

我们知道片元着色器能够控制光栅化后的每个像素的颜色,意味着我们可以绘制出任意复杂的图像,比如画一副风景图,但这是吃力不讨好的行为,有更好的方法来做,就是使用纹理贴图。我们把一幅准备好的风景图作为纹理贴到webGL绘制的图形上就可以了。纹理贴图的原理很简单:向片元着色器提供纹理图像(采样器)和纹理坐标,然后在片元着色器的逐像素操作中,将纹理坐标映射的纹理图像的像素颜色(纹素)提取出来,赋值给光栅化后的像素。

举个栗子可能更清楚:

顶点着色器:

1
2
3
4
5
6
7
8
attribute vec3 position;
attribute vec2 uv; // 传入的纹理坐标
uniform mat4 projection, model;
varying vec2 v_uv; // 纹理坐标传给片元着色器
void main() {
gl_Position = projection * model * vec4(position, 1.0);
v_uv = uv;
}

片元着色器:

1
2
3
4
5
6
7
8
precision mediump float;
uniform sampler2D texture; // 纹理采样器
varying vec2 v_uv; // 片元着色器传入的纹理坐标并进行了插值

void main() {
vec4 textureColor = texture2D(texture, v_uv); // 提取纹理坐标映射的纹素
gl_FragColor = textureColor; // 将纹素颜色赋值给像素
}

JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import glNow from 'gl-now';
import createShader from 'gl-shader';
import createVBO from 'gl-buffer';
import createVAO from 'gl-vao';
import createTexture from 'gl-texture2d'; // 创建纹理对象的模块
import { mat4 } from 'gl-matrix';
import vShader from './shader.vs';
import fShader from './shader.fs';
import gemo from '../common/gemo/square'; // 矩形模型
import { loadImage, arr2to1, updateProjection } from '../common/tools';
import baboon from '../common/img/baboon.png'; // 纹理图像

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
}),
startTime = new Date();

let shader, vao, texture,
projection = mat4.create(),
model = mat4.create();

shell.on('gl-init', function() {
const gl = shell.gl; // 上下文
initShader(gl); // 初始化着色器
initVAO(gl); // 初始化VAO
initTexture(gl); // 初始化纹理
});

shell.on('gl-render', function() {
const gl = shell.gl,
time = new Date() - startTime;

draw(gl, time);
});

shell.on('gl-resize', function() {
const gl = shell.gl;
updateProjection(projection, gl.drawingBufferWidth, gl.drawingBufferHeight);
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

function initShader(gl) {
shader = createShader(gl, vShader, fShader);
shader.attributes.position.location = 0;
shader.attributes.uv.location = 1;
}

function initVAO(gl) {
const positionVBO = createVBO(gl, arr2to1(gemo.positions)),
uvVBO = createVBO(gl, arr2to1(gemo.uvs)),
cellVBO = createVBO(gl, arr2to1(gemo.cells), gl.ELEMENT_ARRAY_BUFFER);

vao = createVAO(gl, [
{
buffer: positionVBO,
size: gemo.positions[0].length,
},
{
buffer: uvVBO,
size: gemo.uvs[0].length,
}
], cellVBO);
}

async function initTexture(gl) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 反转Y轴坐标,使图片坐标系和纹理坐标系一致
const img = await loadImage(baboon); // 异步加载一张图片
texture = createTexture(gl, img); // 创建纹理对象

// 使用纹理过滤
texture.generateMipmap(gl.TEXTURE_2D);
texture.minFilter = gl.LINEAR_MIPMAP_LINEAR;
texture.magFilter = gl.LINEAR;

// 绑定纹理目标gl.TEXTURE_2D为null
// 因为webgl是一个状态机,状态不发生变化会一直保留原来状态
// 先把纹理状态归为null,等需要绘制的时候再绑定回texture
gl.bindTexture(gl.TEXTURE_2D, null);
}

function draw(gl, time) {
shader.bind();
shader.uniforms = { projection, model };
if (texture) {
texture.bind();
shader.uniforms.texture = texture;
}

vao.bind();
vao.draw(gl.TRIANGLES, arr2to1(gemo.cells).length);
vao.unbind();
}

gemo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default {
positions: [
[-0.5, 0.5],
[-0.5, -0.5],
[0.5, 0.5],
[0.5, -0.5]
],
cells: [
[0, 1, 2],
[2, 1, 3]
],
normals: [],
uvs: [
[0.0, 1.0],
[0.0, 0.0],
[1.0, 1.0],
[1.0, 0.0]
],
};

首先纹理坐标是在顶点着色器中定义的,因为它与顶点相关,知道每个顶点的纹理坐标后,传到片元着色器就可以被内插成正确的像素纹理坐标。

然后再看片元着色器,传入了纹理图像和纹理坐标,并在入口函数里完成了纹理采样的过程:

1
2
3
4
5
6
7
uniform sampler2D texture; // 纹理图像(采样器)
varying vec2 v_uv; // 片元着色器传入的纹理坐标并进行了插值

void main() {
vec4 textureColor = texture2D(texture, v_uv); // 提取纹理坐标映射的纹素
gl_FragColor = textureColor; // 将纹素颜色赋值给像素
}

片元着色器里的texture2D函数是GLSL ES内置函数,用于2D纹理的采样。

然后再看JavaScript代码:

1
2
3
4
5
6
7
8
9
10
async function initTexture(gl) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 反转Y轴坐标,使图片坐标系和纹理坐标系一致
const img = await loadImage(baboon); // 异步加载一张图片
texture = createTexture(gl, img); // 创建纹理对象

// 绑定纹理目标gl.TEXTURE_2D为null
// 因为webgl是一个状态机,状态不发生变化会一直保留原来状态
// 先把纹理状态归为null,等需要绘制的时候再绑定回texture
gl.bindTexture(gl.TEXTURE_2D, null);
}

上面的代码就是初始化一个纹理采样器,它将会作为纹理图像传给片元着色器的texture变量。

纹理坐标使用VBO/VAO的方式传给顶点着色器的uv变量,纹理图像是跟顶点无关的数据,它直接通过绑定的着色器对象来传,就和传投影矩阵一样。

再看看矩形的纹理坐标数据,它是铺满整个矩形的。最终得到的效果如下:

2d纹理贴图

上面的栗子只是贴一张纹理图,我们也可以贴多张纹理图,并通过一定的算法混合多幅图像,得到特殊的效果。比如我们经常使用photoshop里面图层的混合方式,比如正片叠底、滤色、线性加深(减淡)等等,都可以实现。其实这很简单,我们把顶点着色器改改:

1
2
3
4
5
6
7
8
precision mediump float;
uniform sampler2D baboonTexture, jadeTexture; // 山魈纹理采样器/砖墙纹理采样器
varying vec2 v_uv; // 纹理坐标
void main() {
vec4 baboonTextureColor = texture2D(baboonTexture, v_uv);
vec4 jadeTextureColor = texture2D(jadeTexture, v_uv);
gl_FragColor = baboonTextureColor * jadeTextureColor; // 简单地混合两个纹理
}

片元着色器传入了两幅纹理图像(采样器),然后在入口main函数中分别计算了两个纹理颜色,然后进行简单的相乘就得到了最终的颜色。很简单,有木有。至于如何传两幅纹理图,就不需要说了。下面列出完整代码:

顶点着色器:

1
2
3
4
5
6
7
8
attribute vec3 position;
attribute vec2 uv; // 传入的纹理坐标
uniform mat4 projection, model; // 投影矩阵、模型矩阵
varying vec2 v_uv;
void main() {
gl_Position = projection * model * vec4(position, 1.0);
v_uv = uv; // 纹理坐标传给片元着色器
}

片元着色器(刚刚介绍过)

JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import glNow from 'gl-now';
import createShader from 'gl-shader';
import createVBO from 'gl-buffer';
import createVAO from 'gl-vao';
import createTexture from 'gl-texture2d';
import { mat4 } from 'gl-matrix';
import vShader from './shader.vs';
import fShader from './shader.fs';
import square from '../common/gemo/square';
import { loadImage, arr2to1, updateProjection } from '../common/tools';
import baboon from '../common/img/baboon.png';
import jade from '../common/img/brick-diffuse.jpg';

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
});

let shader, vao, baboonTexture, jadeTexture,
projection = mat4.create(),
model = mat4.create();

shell.on('gl-init', function() {
const gl = shell.gl; // 上下文
initShader(gl); // 初始化着色器
initVAO(gl); // 初始化VAO
initTexture(gl); // 初始化纹理
});

shell.on('gl-render', function() {
const gl = shell.gl;

draw(gl);
});

shell.on('gl-resize', function() {
const gl = shell.gl;
updateProjection(projection, gl.drawingBufferWidth, gl.drawingBufferHeight);
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

function initShader(gl) {
shader = createShader(gl, vShader, fShader);
shader.attributes.position.location = 0;
shader.attributes.uv.location = 1;
}

function initVAO(gl) {
const positionVBO = createVBO(gl, arr2to1(square.positions)),
uvVBO = createVBO(gl, arr2to1(square.uvs)),
cellVBO = createVBO(gl, arr2to1(square.cells), gl.ELEMENT_ARRAY_BUFFER);

vao = createVAO(gl, [
{
buffer: positionVBO,
size: square.positions[0].length,
},
{
buffer: uvVBO,
size: square.uvs[0].length,
}
], cellVBO);
}

async function initTexture(gl) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 反转Y轴坐标,使图片坐标系和纹理坐标系一致

const jadeImg = await loadImage(jade);
jadeTexture = createTexture(gl, jadeImg);

const baboonImg = await loadImage(baboon);
baboonTexture = createTexture(gl, baboonImg);

gl.bindTexture(gl.TEXTURE_2D, null);
}

function draw(gl) {
shader.bind();
shader.uniforms = { projection, model };

if (jadeTexture && baboonTexture) {
shader.uniforms.baboonTexture = baboonTexture.bind(0); // 绑定到0号纹理单元
shader.uniforms.jadeTexture = jadeTexture.bind(1); // 绑定到1号纹理单元
}

vao.bind();
vao.draw(gl.TRIANGLES, arr2to1(square.cells).length);
vao.unbind();
}

baboon.png
山魈

brick-diffuse.jpg
砖墙

最终效果如下:

多重纹理

但不得不说的一点是下面的代码:

1
2
shader.uniforms.baboonTexture = baboonTexture.bind(0); // 绑定到0号纹理单元并传给着色器
shader.uniforms.jadeTexture = jadeTexture.bind(1); // 绑定到1号纹理单元并传给着色器

webGL提供了最多8个纹理单元,我们必须要将纹理绑定到纹理单元上才能传给着色器。

webGL纹理就介绍到这,下面将介绍下如何绘制多个图形。

绘制多个图形

实际开发中不太可能只画一个图形,所以这里介绍下如何绘制多个图形。如果把绘制多个图形的代码都放在一块,代码量会非常多,而且会非常混乱。笔者考虑将要绘制的每个图形都封装成一个模块。这样结构比较清晰。

举个栗子绘制一个三角形和一个矩形,它们用到了不同的着色器,以下是代码清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import glNow from 'gl-now';
import { mat4 } from 'gl-matrix';
import { updateProjection } from '../common/tools';
import Triangle from './Triangle'; // 引入三角形模块
import Square from './Square'; // 引入矩形模块

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
}),
startTime = new Date();

let projection = mat4.create(),
triangle, square;

shell.on('gl-init', function() {
const gl = shell.gl;
triangle = new Triangle(gl);
square = new Square(gl);
});

shell.on('gl-render', function() {
const time = new Date() - startTime;

// 画三角形
const deg = Math.sin( time / 1e3 ) * Math.PI;
triangle
.rotate(deg, new Float32Array([0.0, 0.0, 1.0]))
.translate(new Float32Array([0.0, 0.75, 0.0]))
.draw(projection);

// 画矩形
const distance = Math.sin( time / 1e3 );
square
.translate(new Float32Array([distance, -0.75, 0.0]))
.draw(projection);
});

shell.on('gl-resize', function() {
const gl = shell.gl;
updateProjection(projection, gl.drawingBufferWidth, gl.drawingBufferHeight);
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

Triangle.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import createShader from 'gl-shader';
import createGeometry from 'gl-geometry'; // 代替vao和vbo
import { mat4 } from 'gl-matrix';
import gemo from '../common/gemo/triangle';
import vShader from './shader/triangle.vs';
import fShader from './shader/triangle.fs';

const transfromStack = []; // 变换模型矩阵的函数栈

export default class Triangle {

constructor(gl) {
this.shader = createShader(gl, vShader, fShader);

this.gemo = createGeometry(gl)
.attr('position', gemo.positions, { size: 2 })
.attr('color', gemo.positionColors)
.faces(gemo.cells);

this.model = mat4.create();
}

draw(projection) {
this.shader.bind();
this.shader.uniforms = {
projection: projection,
model: this.model,
};

// 执行模型变换
mat4.identity(this.model);
while (transfromStack.length > 0) {
const transformFn = transfromStack.pop();
transformFn();
}

this.gemo.bind(this.shader);
this.gemo.draw();
this.gemo.unbind();
}

translate(vector) {
transfromStack.push(() => {
mat4.translate(this.model, this.model, vector);
});
return this;
}

rotate(deg, vector) {
transfromStack.push(() => {
mat4.rotate(this.model, this.model, deg, vector);
});
return this;
}

scale(vector) {
transfromStack.push(() => {
mat4.scale(this.model, this.model, vector);
});
return this;
}
}

Square.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import createShader from 'gl-shader';
import createGeometry from 'gl-geometry'; // 代替vao和vbo
import { mat4 } from 'gl-matrix';
import createTexture from 'gl-texture2d';
import gemo from '../common/gemo/square'; // 矩形模型
import vShader from './shader/square.vs';
import fShader from './shader/square.fs';
import { loadImage } from '../common/tools';
import baboon from '../common/img/baboon.png';

const transfromStack = []; // 变换模型矩阵的函数栈

export default class Triangle {

constructor(gl) {
this.shader = createShader(gl, vShader, fShader);

this.gemo = createGeometry(gl)
.attr('position', gemo.positions, { size: 2 })
.attr('uv', gemo.uvs, { size: 2 })
.faces(gemo.cells);

this.model = mat4.create();

this._initTexture(gl);
}

draw(projection) {
this.shader.bind();
this.shader.uniforms = {
projection: projection,
model: this.model,
};
if (this.texture) {
this.texture.bind();
this.shader.uniforms.texture = this.texture;
}

// 执行模型变换
mat4.identity(this.model);
while (transfromStack.length > 0) {
const transformFn = transfromStack.pop();
transformFn();
}

this.gemo.bind(this.shader);
this.gemo.draw();
this.gemo.unbind();
}

translate(vector) {
transfromStack.push(() => {
mat4.translate(this.model, this.model, vector);
});
return this;
}

rotate(deg, vector) {
transfromStack.push(() => {
mat4.rotate(this.model, this.model, deg, vector);
});
return this;
}

scale(vector) {
transfromStack.push(() => {
mat4.scale(this.model, this.model, vector);
});
return this;
}

async _initTexture(gl) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);

const img = await loadImage(baboon);
this.texture = createTexture(gl, img);
gl.bindTexture(gl.TEXTURE_2D, null);
}
}

最终效果如下:

绘制多个