WebGL Programing Guide 学习笔记

一些在 2D 中使用 shader 的基础知识,暂不涉及 3D。

GLSL ES 的一些有趣语法

参数修饰符

out 可以代替 return,此时返回值类型得改成 void。而 in 可以省略,这是参数的默认属性。

void luma2 (in vec3 color, out float brightness) {
    brightness = 0.2126 * color.r + 0.7162 * color.g + 0.0722 * color.b;
}

想起函数式编程中纯函数的概念,在 JS 中只能依靠约定保证参数在函数执行过程中不被修改。

精度修饰符

更高的精度显然需要更大的存储空间。GLSL 给予开发者权衡性能的选择。

#ifdef GL_ES
precision mediump float;
#endif

高效存储 vertex 数据

如果想绘制多个点,多次调用 drawArray() ,每次绘制一个点显然不是一个高效的方法:

// g_points = [x1, y1, x2, y2...]
for(var i = 0; i < len; i += 2) {
    // a_Position 为 attribute 地址,向地址中写入 xyz
    gl.vertexAttrib3f(a_Position, g_points[i], g_points[i+1], 0.0);
    // 从第 0 个 vertex 开始,绘制 1 个
    gl.drawArrays(gl.POINTS, 0, 1);
}

一次性向 vertex shader 传递多个顶点信息,就需要使用 Buffer 了:

  1. 创建一个 buffer 对象 gl.createBuffer()
  2. 绑定 buffer 对象到一个 target gl.bindBuffer()
  3. 向 buffer 对象写入数据 gl.bufferData()
  4. 分配 buffer 对象到 vertex shader 中的 attribute 变量 gl.vertexAttribPointer()
  5. 开启分配完成传值 gl.enableVertexAttribArray()

使用 Buffer 步骤

使用 Buffer 步骤

之前只是在 Buffer 中存储了各个顶点的位置信息,如果每个顶点有不同的大小呢? 使用多个 Buffer 看似可行,但是一旦顶点数量增多,或者每个顶点又需要不同颜色,多个 Buffer 的问题就显现出来了。 这时候就需要在同一个 Buffer 中 分组 存储全部的顶点信息,也叫 interleaving

var verticesSizes = new Float32Array([
    // 混合存储顶点坐标和大小
    0.0, 0.5, 10.0, // The 1st point
    -0.5, -0.5, 20.0, // The 2nd point
    0.5, -0.5, 30.0 // The 3rd point
]);

gl.vertexAttribPointer() 最后两个参数可以指定 stride 的长度以及当前顶点信息在每个 stride 中的偏移量(offset):

Stride & Offset

Stride & Offset

这样就实现了在一个 Buffer 中存储全部顶点信息。值得注意的是,目前我们存储的顶点信息都是给 vertex shader 使用的,如果想要给 fragment shader 传递变量,例如顶点颜色,该如何使用呢?

shader 间传递变量

从 vertex shader 向 fragment shader 传递变量需要使用 varying,为啥不是熟悉的 attribute 而需要这个额外的看起来像桥梁一样的新东西呢?

varying 变量

varying 变量

要弄清这个问题,我们得知道 shader 间传值并不是这么简单的,中间会经历两个步骤:

vertex shader 和 fragment shader 之间

vertex shader 和 fragment shader 之间

可见在 vertex shader 和 fragment shader 中虽然变量同名(v_Color),但事实上并不是一一对应的关系。这也是 varying 名称的由来,线性插值会生成 vertex 间像素的值:

线性插值

线性插值

在下面的例子中,三个顶点红绿蓝,借助线性插值,能生成平滑的效果:

See the Pen dmgLEK by xiaop (@xiaoiver) on CodePen.

书中的例子使用了 Google 编写的 webgl-utils,但是在各个 CDN 上都没有找到。我使用了 webglfundamentals.org 提供的 WebGL 工具库,完成加载 shader,创建 program 的工作,一定不能忘记调用 gl.useProgram()

texture 纹理

使用纹理是一个比较复杂的过程。

坐标转换

首先图片(PNG,JPEG)坐标系不同于 WebGL 坐标系,需要进行转换:

图片坐标到 WebGL 坐标映射

图片坐标到 WebGL 坐标映射

只需要反转一下 Y 轴即可:

gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);

然后我们需要将 texture 坐标映射到 vertex shader 坐标系,例如这里只是简单平移一下,并不存在缩放。最终显示在 -1 ~ 1 的坐标系中:

纹理坐标映射

纹理坐标映射

纹理坐标存储在 a_TexCoord 中:

var verticesTexCoords = new Float32Array([
    // Vertex coordinates, texture coordinate
    -0.5,  0.5,   0.0, 1.0,
    -0.5, -0.5,   0.0, 0.0,
     0.5,  0.5,   1.0, 1.0,
     0.5, -0.5,   1.0, 0.0,
]);
var a_TexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
// Assign the buffer object to a_TexCoord variable
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2);
gl.enableVertexAttribArray(a_TexCoord);

创建纹理对象

WebGL 中规定浏览器实现中至少有 gl.TEXTURE0gl.TEXTURE7 这8个纹理单位(可能更多),每一个都可以关联到一种纹理类型,比如 gl.TEXTURE_2D。有点类似之前每一个 Buffer 对象都可以关联 gl.ARRAY_BUFFER 或者 gl.ARRAY_ELEMENT_BUFFER。甚至连创建语法也很相似:

// 创建 texture
var texture = gl.createTexture();
// Enable texture unit0
gl.activeTexture(gl.TEXTURE0);
// Bind the texture object to the target
gl.bindTexture(gl.TEXTURE_2D, texture);

关联纹理对象

关联纹理对象

完成绑定之后,可以查询当前的纹理单元,但是要注意返回值不是简单的 01,需要使用 WebGL 内置常量:

var activeTextureUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
if (activeTextureUnit == gl.TEXTURE0) {}

指定纹理参数

完成了纹理对象的创建,激活和绑定,随后需要设置一些纹理参数。这些参数指定了在某些场景下纹理的规则:

4种纹理参数和效果

4种纹理参数和效果

「Professional WebGL Programming: Developing 3D Graphics for the Web」这本书介绍的更为详细。比如需要放大纹理贴图的场景,如果选择了 gl.NEAREST 参数,每个像素点的颜色就按照最近的 texel 的颜色设置。 显然,这种方式计算起来是很快的,但是会呈现 pixelation 现象,十分生硬不自然。而 gl.LINEAR 参数会考虑四个 texel 的色值计算插值,但是放大后会呈现模糊的效果。

在缩小时,不管使用 gl.NEAREST 还是 gl.LINEAR 都会造成锯齿化。

为了抗锯齿,可以提供多张不同分辨率的纹理,这种做法也叫做 mipmapping。 在具体使用时,只要传入一个基础纹理,WebGL 会生成 44 22 1*1 等等不同 mipmap level 的小纹理。 当然也可以手动上传多张分辨率的纹理。

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.generateMipmap(gl.TEXTURE_2D);

应用之后 gl.TEXTURE_MIN_FILTER 就多了四种选择,也很好理解:

这里不得不吐槽下 WebGL 内置的一些函数命名,虽然有些通过函数名就能判断参数类型和数目,但是像 texParameteri()vertexAttribPointer() 这样部分缩写实在很难记忆。

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

上传图片到纹理对象中

接下来需要将图片上传到纹理对象中。 在 WebGL 中,使用 new Image() 异步加载图片十分简单,以下操作都在成功回调函数中完成。 这里需要根据图片格式指定纹理数据格式,例如对于 PNG 格式的图片,纹理数据必须使用 gl.RGBA,而对于 JPEG 格式,则使用 gl.RGB

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);

这里多个参数,另一本「Professional WebGL Programming」中有更详细的介绍。有趣的是第三四个参数在 WebGL 中必须是完全相同的,而第五个参数需要与之匹配。比如:

最常见的 RGBA 格式,每个通道各占据一个 byte,这样每个像素点总共 4 个 bytes。

format - type - bytes/texel
gl.RGBA	- gl.UNSIGNED_BYTE - 4

RGBA 格式也可以更精确的指定每个通道占据的 bits,比如 RGB 各 5 bits,A 通道 1 bit,这样总共是 2 bytes。

format - type - bytes/texel
gl.RGBA	- gl.UNSIGNED_SHORT_5_5_5_1 - 2

fragment shader 中的操作

在 fragment shader 中读取纹理对象需要通过 sampler 完成。这是一个常量 uniform sampler2D u_Sampler;

之前纹理对象存储在 gl.TEXTURE0 中,这里只需要传一个序号就行了。

gl.uniform1i(u_Sampler, 0);

根据从 vertex shader 中传过来的坐标,使用 sampler,fragment shader 就能获取当前纹理中每个 fragment 对应的颜色:

gl_FragColor = texture2D(u_Sampler, v_TexCoord);

总结下,完整使用 texture 流程如下:

纹理完整流程

纹理完整流程

多个纹理混合

首先异步加载多张图片,需要保证全部加载完成再进行后续操作。 在 fragment shader 中,由于颜色都是用 vec 向量表示,可以相乘,效果就是 rgba 各个部分相乘:

gl_FragColor = color0 * color1;

这种混合方式和 CSS 中 background-blend-mode一种混合方式一致。在 PS 中也有类似图层混合方法:

.simple-blended {
    background-image: url(image.jpg);
    background-color: red;
    background-blend-mode: multiply;
}