Cocos Shader 基础入门(三):高效绘制多个三角形并给顶点换个颜色

在[上一章]里我们成功地用 WebGL 在画布上绘制出了一个三角形。这一章,我们继续拓展上一章的内容,绘制一些不一样的东西。

本章主要围绕以下两个知识点展开: 1. 高效绘制多个三角形;2. 绘制不同颜色的三角形。

高效绘制多个三角形

看到这个标题,肯定很多同学会想:画一个三角形我会了,多画几个还不容易?

// 首先,改顶点,绘制两个三角形,让它们构成一个矩形

const positions = [
0, 0,
0, 0.5,
0.7, 0,
0, 0.5,
0.7, 0.5,
0.7, 0
];

// 其次,绘制的时候,增加绘制的顶点数量
gl.drawArrays(gl.TRIANGLES, 0, 6);

结果最终绘制出来是这样的:

01

这样绘制实际上是没有问题的,但应该会有细心的朋友发现,有两个顶点数据重复提交了,原本用 4 个顶点就能绘制矩形,现在要用 6 个,白白多产生了 50% 的开销!如果场景很庞大,这个问题就会越来越严重。因此,可以采用的解决方案是: 提供最优顶点,之后只需要指定绘制的顺序就可以了, 我们把指定绘制顺序的方法称之为顶点索引。之前我们采用“gl.drawArrays” 的方式直接绘制顶点,接下来,我们需要使用顶点索引的方法 “gl.drawElements” 来绘制。改造一下上面的内容:

// 只提供四个顶点
const positions = [
0, 0,
0, 0.5,
0.7, 0,
0.7, 0.5,
];

// 同时提供索引数组,从 positions 中取顶点构造顺序
// 索引从数组的 0 开始
const indices = [
0, 1, 2, // 第一个三角形
2, 1, 3  // 第二个三角形
];

// 对 “步骤二:顶点着色器” 顶点缓冲和绑定这里进行修改
// 上传顶点缓冲
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
// 上传索引缓冲
const indexBuffer = gl.createBuffer();
// ELEMENT_ARRAY_BUFFER 是专门用来绑定索引缓冲的
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// 因为索引不会有小数点,所以取用无符号 16 位整型,合理分配内存
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);

// 最后,将绘制使用的 API 改为使用索引缓冲绘制
// gl.drawArrays(gl.TRIANGLES, 0, 6); 去除该部分内容,改为如下:

gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// gl.drawElements(primitiveType, count, indexType, offset);
// 部分参数与 gl.drawArrays 一致。indexType:指定元素数组缓冲区中的值的类型。有 gl.UNSIGNED_BYTE、gl.UNSIGNED_SHORT 以及扩展类型
// gl.UNSIGNED_BYTE 最大索引值为 255,gl.UNSIGNED_SHORT 最大索引值为 65535
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);

给顶点换个颜色

在之前的例子里,我们绘制的三角形或矩形的颜色都是在片段着色器里定义好的固定颜色——玫红色。如果希望在运行时动态修改颜色,这种方式就无法满足需求,因此,我们需要能动态设置输入的接口。

Uniform

Uniform 是一种从 CPU 中的应用向 GPU 中的着色器发送数据的方式,但 Uniform 和顶点属性有些不同。 Uniform 是全局的,意味着每一个 Uniform 变量在每个着色器程序对象中是独一无二的,可以被着色器程序的任意着色器在任意阶段访问。 并且,Uniform 会始终保持它自身的值,除非它被重置或者更新。接着,进行一点修改:

const fragmentShaderSource = `
precision mediump float;
// 因为 uniform 是全局变量,可以在任何着色器中定义它们,而无需通过顶点着色器作为中介
uniform vec4 u_color;
// 着色器入口函数
void main() {
gl_FragColor = u_color;
}`;

// ...
// gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
const vertexColorLocation = gl.getUniformLocation(program, 'u_color');

// 此处 color 要的可以是 4 个浮点型 color 或者一个浮点型数组,更多请看下方 Uniform 后缀问题。
// 使用 4 个浮点型
gl.uniform4f(vertexColorLocation, Math.random(), Math.random(), Math.random(), 1.0);
// 或者一个浮点型数组
gl.uniform4fv(vertexColorLocation, [Math.random(), Math.random(), Math.random(), 1.0]);

运行后可以看到, 所有顶点颜色都随机变成了同样一种颜色。 这种设置方式相对来说比较统一,但如果希望不同的顶点采用不同的颜色,此方法则无法满足。

Uniform 后缀问题


什么是 WebGL?

如果希望每个顶点都有自己所属的颜色,此时可以通过顶点属性的方式来实现。WebGL 中可以获得顶点着色器中输出的三个颜色的值,在光栅化的时候根据这三个值进行插值,也就是如果三角形每个顶点的颜色都不一样,那么 WebGL 会在顶点 a 和顶点 b 之间进行像素插值。所以,接下来我们需要给片段着色器传顶点颜色值,让它按照我们给的值进行绘制。改造内容如下:

const vertexShaderSource = `
// 接收顶点位置数据
attribute vec2 a_position;
// 增加顶点颜色数据
attribute vec4 a_color;
// 输出顶点颜色数据给片元着色器
varying vec4 v_color;
// 着色器入口函数
void main() {
v_color = a_color;
// gl_Position 接收的就是一个 vec4,因此需要转换
gl_Position = vec4(a_position, 0.0, 1.0);
}`;

// 首先,因为定义的是顶点颜色值,因此,可以将这部分数据写进顶点缓冲里,顶点缓冲之前说了,是可以支持多个属性的
// 在这里直接将顶点分别赋值不同颜色
const positions = [
0, 0, 1, 0, 0, 1,
0, 0.5, 0, 1, 0, 1,
0.7, 0, 0, 0, 1, 1,
0.7, 0.5, 1, 0.5, 0, 1
];

const fragmentShaderSource = `
precision mediump float;
// 接收来自顶点着色器的颜色属性
varying vec4 v_color;
// 着色器入口函数
void main() {
gl_FragColor = v_color;
}`;

// 此时多了颜色的数据,这个数据同样也需要传输给顶点着色器,因此需要在顶点着色器上申明颜色输入 a_color
// 接下来在顶点缓冲的数据传送给 a_position 后,继续将数据传输给 a_color
// 也就是在“步骤六”和“步骤七”处进行下列修改
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttributeLocation);
const colorAttributeLocation = gl.getAttribLocation(program, "a_color");
gl.enableVertexAttribArray(colorAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// gl.vertexAttribPointer(positionAttributeLocation, size, type, normalize, stride, offset);
// 此时由于顶点缓冲里的 position 顺序已经不连续了,因此需要修改 stride 的值,此时一个顶点的步长是 2x4 + 4x4 = 24
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 24, 0);
// 此时由于顶点缓冲里的 color 相关的顺序不是从 0 开始,因此需要修改 offset 的值,此时它在一个顶点的偏移是 2x4 = 8
gl.vertexAttribPointer(colorAttributeLocation, 4, gl.FLOAT, false, 24, 8);

最终效果如下:

看到这个图是不是有些意外?我们只提供了3个颜色,这里居然帮我们自动过渡了。这其实是在片段着色器中进行的所谓片段插值的结果。当渲染一个三角形时,光栅化阶段通常会造成比原指定顶点更多的片段。光栅会根据每个片段在三角形形状上所处的相对位置来决定这些片段的位置,然后再根据这些位置,对所有片段着色器的输入变量进行插值。比如:一个线段,头尾分别是蓝色和绿色,如果此时运行到线段 70% 的位置,它的颜色输入属性就会是一个绿色和蓝色的线性结合,更精确地说就是 70% 蓝 + 30% 绿。

一个三角形每个顶点属性分布如下:

当然,上方将颜色属性写入主要是为了说明一下顶点缓冲是可以支持多属性的,但这其实并不是一个好方法。因为顶点需要 float 数值来记录,而 color 并不需要,毕竟 color 的数值范围是从 0-255,一个字节就足够了,用 float 来存储简直就是天大的浪费!因此,需要进行如下改造:

const positions = [
0, 0,
0, 0.5,
0.7, 0,
0.7, 0.5,
];
const color = [
255, 0, 127, 255,
127, 255, 0, 255,
0, 127, 255, 255,
255, 127, 127, 255
];

// 思路一(推荐):不新增内存,一次申请一个大的内存,position 和 color 共享内存
const arrayBuffer = new ArrayBuffer(positions.length * Float32Array.BYTES_PER_ELEMENT + colors.length);
const positionBuffer = new Float32Array(arrayBuffer);
const colorBuffer = new Uint8Array(arrayBuffer);
// 当前顶点属性结构方式是 pos + color
// 按 float 32 分布 pos(2)+ color(1)
// 按子节分布 pos(2x4)+ color(4)
let offset = 0;
for (let i = 0; i < positions.length; i += 2) {
// 位置时按每浮点数填充
positionBuffer[offset] = positions[i];
positionBuffer[offset + 1] = positions[i + 1];
offset += 3;
}

offset = 8;
for (let j = 0; j < colors.length; j += 4) {
// 颜色值时按每子节填充
colorBuffer[offset] = colors[j];
colorBuffer[offset + 1] = colors[j + 1];
colorBuffer[offset + 2] = colors[j + 2];
colorBuffer[offset + 3] = colors[j + 3];
// 一个 stride,2 个 position 的 float,加 4 个 unit8,2x4 + 4 = 12
offset += 12;
}
// 注意,此处 vertexBuffer 绑定的就是一条可以容纳 float 32 positions 和 uint 8 colors 的大 buffer
gl.bufferData(gl.ARRAY_BUFFER, arrayBuffer, gl.STATIC_DRAW);

const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttributeLocation);
const colorAttributeLocation = gl.getAttribLocation(program, "a_color");
gl.enableVertexAttribArray(colorAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 12, 0);
// 此时由于顶点缓冲里的 color 相关的顺序不是从 0 开始,因此需要修改 offset 的值,此时它在一个顶点的偏移是 2x4 = 8。
// 颜色数据不是归一化数据,因此启用归一化
gl.vertexAttribPointer(colorAttributeLocation, 4, gl.UNSIGNED_BYTE, true, 12, 8);

// 思路二:新增一条缓存,position 和 color 不共享内存
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Uint8Array(color), gl.STATIC_DRAW);

const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);

const colorAttributeLocation = gl.getAttribLocation(program, "a_color");
gl.enableVertexAttribArray(colorAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
// 由于 WebGL 里使用的数据都是标准化数据,由于此时的颜色值是 0-255,所以此时需要将数据归一化到 0-1,normalize 参数设置为 true
gl.vertexAttribPointer(colorAttributeLocation, 4, gl.UNSIGNED_BYTE, true, 0, 0);

到这里为止我们对 WebGL 的基础工作原理有了一定了解,下一章,试着给三角形换个“皮肤”吧。

内容参考:

  1. WebGL 基础

  2. WebGL API 对照表

  3. OpenGL 中文文档

要看下一集?戳这里看全系列教程>>>>

2赞

消灭零回复

消灭一回复

消灭二回复

消灭零号 一二三号回复

统一回复。。

而 color 并不需要,毕竟 color 的数值范围是从 0-255,一个字节就足够了,用 float 来存储简直就是天大的浪费,cocos creator 3.3里面assembler的结构就改成float了

wow,amazing!