绘制点线面

上一节通过一个简单的案例对比了 Canvas 2D 和 WebGL 在绘制一个点上的差异,初步介绍了 WebGL 的初始化、着色器语法、着色器代码加载以及坐标系差异。接下来再介绍点线面的绘制,进一步了解上一节中使用的相关代码。

众所周知,点是图形的基础,线是两个点的连线组成的,面是三个点以上的连线组成的。为了在着色器中添加更多的点,这个时候就涉及到 JavaScript 和着色器代码之间的通信了。

着色器通信

接下来我们定义 6 个点,来组成我们想要的点线面(注意坐标系的中心点是在画布中央,所以 X 轴的范围是 -1.0~1.0,Y 轴同理,这里只展示二维图形,故 Z 统一为 0)

var points = [
    -0.7, 0.0, 0.0,     // v1             |
    -0.2, 0.4, 0.0,     // v2          *  |  *   *
    0.0, -0.4, 0.0,     // v3   --*-------|----------
    0.7, 0.4, 0.0,      // v4             *      *
    0.4, 0.4, 0.0,      // v5             |
    0.7, -0.4, 0.0      // v6
]

如何把这些顶点信息传到顶点着色器呢,首先在顶点着色器中声明变量:

<!-- 定义顶点着色器 -->
<script id="vertexShader" type="x-shader/x-vertex">
    attribute vec3 a_Position;
    void main() {
        gl_Position = vec4(a_Position, 1.0);  // 设置点的位置
        gl_PointSize = 5.0;                   // 设置点的大小
    }
</script>

通过定义一个 vec3 的向量来表示 xyz 坐标,同时将其作为前三个值传入 vec4 变量中(这是 Shader 语法的便捷之处),实现对输入数据的定义。

attribute 被称为存储限定符(storage qualifier),它具备以下特点:

  • 只能在顶点着色器中使用(片元着色器则不允许使用 attribute)
  • 只能作为全局变量存在,即不能写入 main 函数中
  • 一般用 attribute 变量来表示一些顶点的数据,如:顶点/纹理坐标、法线、顶点颜色等

接下来借助以下函数将顶点数据传输到着色器中:

// 定义6个点
var points = [
    -0.7, 0.0, 0.0,     // v1
    -0.2, 0.4, 0.0,     // v2
    0.0, -0.4, 0.0,     // v3
    0.7, 0.4, 0.0,      // v4
    0.4, 0.4, 0.0,      // v5
    0.7, -0.4, 0.0      // v6
]

// 获取 attribute 变量的存储位置
// 一般我们习惯在 attribute 变量命名里加一个前缀 a_
var a_Position = gl.getAttribLocation(gl.program, 'a_Position')
if (a_Position < 0) {
    console.log('Failed to get the storage location of a_Position')
    return
}

// 将顶点位置传输给 attribute 变量
// vertexAttrib3f 的同族函数有:vertexAttrib1f/vertexAttrib2f/vertexAttrib4f
// 数字表示其传值的数量,f 表示传值类型(浮点型)
for(var i=0; i<points.length; i+=3) {
    gl.vertexAttrib3f(a_Position, points[i], points[i+1], points[i+2])
    gl.drawArrays(gl.POINTS, 0, 1)
}

See the Pen 4. Draw Point & Line & Triangle (part1) - WebGL by ShaderFans (@shaderfans) on CodePen.

看完源码的你一定会觉得这种循环传值的方式太繁琐了,现实中往往需要一次性传入非常多的顶点(如加载 3D 模型),所以需要批量导入。WebGL 提供了一种很方便的机制,即「缓冲区对象(buffer object)」来让我们可以一次性向着色器传入多个顶点数据:

// Float32Array:单精度 32 位浮点数
// WebGL 引入的一种类型化数组,通常用于存储顶点坐标和颜色数据
// 通过明确数据类型来提升 WebGL 的处理效率和性能,与 Array 不同的是:不支持 push() 和 pop()
var points = new Float32Array([
    -0.7, 0.0, 0.0,     // v1
    -0.2, 0.4, 0.0,     // v2
    0.0, -0.4, 0.0,     // v3
    0.7, 0.4, 0.0,      // v4
    0.4, 0.4, 0.0,      // v5
    0.7, -0.4, 0.0      // v6
]);

// 获取 attribute 变量的存储位置
var a_Position = gl.getAttribLocation(gl.program, 'a_Position')
if (a_Position < 0) {
    console.log('Failed to get the storage location of a_Position')
    return
}

// 创建缓冲区对象
var pointsBuffer = gl.createBuffer()
if (!pointsBuffer) {
    console.log('Failed to create the buffer object')
    return -1;
}

// 绑定缓冲区对象
gl.bindBuffer(gl.ARRAY_BUFFER, pointsBuffer)

// 写入缓冲区对象
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW)

// 将缓冲区对象赋值于 attribute 变量中
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0)

// 开启 attribute 变量(使顶点着色器能够访问缓冲区内的数据)
gl.enableVertexAttribArray(a_Position)

// 一次性绘制6个点
gl.drawArrays(gl.POINTS, 0, 6)

See the Pen 5. Draw Point & Line & Triangle (part2) - WebGL by ShaderFans (@shaderfans) on CodePen.

点绘制完了,接下来就将它们连线,这个时候就需要特别讲讲这个强大的函数 drawArrays()了:

// 函数说明来自《WebGL 编程指南》
// mode:绘制模式(详见下表格)
// first:指定从哪个顶点开始绘制
// count:执行绘制需要用到多少顶点
void gl.drawArrays(mode, first, count);
基本图形 参数 mode 描述
gl.POINTS 一系列点,绘制在 v0, v1, v2...处
线段 gl.LINES 一系列单独的线段,绘制在 (v0,v1)、(v2,v3)...处,如果点的个数是奇数,最后一个点将被忽略
线条 gl.LINE_STRIP 一系列连接的线段,绘制在 (v0,v1)、(v1,v2)...处,第1个点是第一条线段的起点,第二个点是第1条线段的终点和第二条线段的起点...以此类推。最后一个点事最后一条线段的终点
回路 gl.LINE_LOOP 一系列连接的线段,与 gl.LINE_STRIP 绘制的线相比,增加了一条从最后一个点到第一个点的线段
三角形 gl.TRIANGLES 一系列单独的三角形,绘制在 (v0,v1,v2)、(v3,v4,v5)...处,如果点的个数不是3的整数倍,则忽略剩下的1个或2个点
三角带 gl.TRIANGLE_STRIP 一系列条带状三角形,前三个点构成了第一个三角形,从第2个点开始的三个点构成了第二个三角形,该三角形与前一个三角形共享一条边,以此类推。注意绘制顺序:(v0,v1,v2)、(v2,v1,v3)、(v3,v2,v4)...
三角扇 gl.TRIANGLE_FAN 一系列三角形组成的类似于扇形的图形。前三个点构成了第一个三角形,接下来的一个点和前一个三角形的最后一条边 组成接下来的三角形。这些三角形被绘制在 (v0,v1,v2)、(v0,v2,v3)、(v0,v3,v4)...处

文字总是苍白的,下面通过一个 Demo 让你更好地理解 mode 参数可以绘制什么图形(切换左下角下拉菜单可见区别)

See the Pen 7. Understand drawArrays - WebGL by ShaderFans (@shaderfans) on CodePen.

通过对 drawArrays() 的灵活应用,我们可以很轻松地绘制出想要的形状:

// 点
var point = new Float32Array([
    -0.7, 0.0, 0.0      // v1
]);
// 线
var line = new Float32Array([
    -0.2, 0.4, 0.0,     // v2
    0.0, -0.4, 0.0      // v3
]);
// 面
var triangle = new Float32Array([
    0.7, 0.4, 0.0,      // v4
    0.4, 0.4, 0.0,      // v5
    0.7, -0.4, 0.0      // v6
]);

...

// 绘制点
gl.bufferData(gl.ARRAY_BUFFER, point, gl.STATIC_DRAW)
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(a_Position)
gl.drawArrays(gl.POINT, 0, 1)

// 绘制线
gl.bufferData(gl.ARRAY_BUFFER, line, gl.STATIC_DRAW)
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(a_Position)
gl.drawArrays(gl.LINES, 0, 2)

// 绘制面
gl.bufferData(gl.ARRAY_BUFFER, triangle, gl.STATIC_DRAW)
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(a_Position)
gl.drawArrays(gl.TRIANGLES, 0, 3)

See the Pen 6. Draw Point & Line & Triangle (part3) - WebGL by ShaderFans (@shaderfans) on CodePen.

为图形添加颜色

接下来我们尝试给这些图形赋予颜色,之前我们的片元着色器统一设置为白色: gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);。 假设希望每个图形都有各自的颜色,那就需要对每个顶点定义一个颜色,这就意味着同样的需要批量导入颜色数组。

首先分析着色器有什么变化:

<!-- 定义顶点着色器 -->
<script id="vertexShader" type="x-shader/x-vertex">
    attribute vec3 a_Position;
    attribute vec3 a_Color;
    varying vec3 v_Color;
    void main() {
        gl_Position = vec4(a_Position, 1.0);
        gl_PointSize = 5.0;
        v_Color = a_Color;
    }
</script>

<!-- 定义片元着色器 -->
<script id="fragmentShader" type="x-shader/x-fragment">
    varying vec3 v_Color;
    void main() {
        gl_FragColor = vec4(v_Color, 1.0); // 设置顶点颜色
    }
</script>

除了新增一个 attribute 变量 a_Color 来表示顶点的 rgb 颜色之外,还新增了一个 varying 变量 v_Color (正如 attribute 的变量以 a_ 开头,varying 变量则以 v_ 开头)。varying 和 attribute 有和区别?

varying 变量是顶点和片元着色器之间做数据传递用的。一般在顶点着色器中修改 varying 变量的值,然后在片元着色器中使用该 varying 变量的值。因此 varying 变量在着色器之间的声明必须是一致的。

所以顶点着色器和片元着色器中都需要有该定义语句:varying vec3 v_Color;。 顶点着色器中赋值:v_Color = a_Color;,片元着色器中使用:gl_FragColor = vec4(v_Color, 1.0);

既然 attribute 专属于顶点着色器,varying 用户顶点和片元着色器通信,那自然而然需要有另外一个存储限定符用于片元着色器,这个就是 uniform(注意 uniform 并非片元着色器独占,顶点着色器中也可以使用)

uniform 变量是外部程序传递给顶点和片元着色器的变量,注意在两个着色器中都可以使用 uniform。而且如果 uniform 变量在着色器两者之间声明方式完全一样,则它可以被共享使用。同样的,我们习惯给 uniform 变量一个 u_ 前缀。

对于 a_Color,我们可以像 a_Position 一样创建缓冲区对象进行赋值,当然也可以直接复用同一个缓冲区,具体做法如下:

// 分别在每个点后面增加了 rgb 色值
// 点
var point = new Float32Array([
    -0.7, 0.0, 0.0, 1.0, 0.5, 0.5       // v1: position + color
]);
// 线
var line = new Float32Array([
    -0.2, 0.4, 0.0, 0.0, 1.0, 1.0,      // v2
    0.0, -0.4, 0.0, 1.0, 1.0, 0.0       // v3
]);
// 面
var triangle = new Float32Array([
    0.7, 0.4, 0.0, 1.0, 0.0, 1.0,       // v4
    0.4, 0.4, 0.0, 1.0, 1.0, 0.0,       // v5
    0.7, -0.4, 0.0, 0.0, 1.0, 0.0       // v6
]);

...

// 获取 attribute 变量的存储位置(顶点颜色)
var a_Color = gl.getAttribLocation(gl.program, 'a_Color')
if (a_Color < 0) {
    console.log('Failed to get the storage location of a_Color')
    return
}

...

// 获取数组中每个数据元素占用字节数
var FSIZE = point.BYTES_PER_ELEMENT;

// 绘制点(线、面同理)
gl.bufferData(gl.ARRAY_BUFFER, point, gl.STATIC_DRAW)
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0)
gl.enableVertexAttribArray(a_Position)
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 6, FSIZE * 3)
gl.enableVertexAttribArray(a_Color)
gl.drawArrays(gl.POINT, 0, 1)

这里需要特别讲讲 vertexAttribPointer() 这个函数:

// index:指定待分配 attribute 变量的存储位置
// size:指定缓冲区中每个顶点的分量个数
// type:指定数据格式类型:gl.SHORT、gl.INT、gl.FLOAT 等
// normalized:是否将非浮点型的数量归一化
// stride:指定相邻两个顶点间的字节数,默认为0
// offset:指定缓冲区对象中的偏移量(以字节为单位)
void gl.vertexAttribPointer(index, size, type, normalized, stride, offset);

通过上图相信大家对每个参数的含义都有一定的了解了,在 WebGL 中这种组织数据的方式被称作:交替组织数据(Interleaved Array)。所谓交替,指的是 buffer 中可以包含顶点多个不同的属性,不同属性交替在 buffer 中放置。

See the Pen 8. Draw Point & Line & Triangle (part4) - WebGL by ShaderFans (@shaderfans) on CodePen.

看完 Demo 大家可能会疑问:我明明只对顶点设置了颜色,为什么会存在渐变?欲深入了解内部机制可查看下一小节扩展阅读。