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 了:
- 创建一个 buffer 对象
gl.createBuffer()
- 绑定 buffer 对象到一个 target
gl.bindBuffer()
- 向 buffer 对象写入数据
gl.bufferData()
- 分配 buffer 对象到 vertex shader 中的 attribute 变量
gl.vertexAttribPointer()
- 开启分配完成传值
gl.enableVertexAttribArray()
之前只是在 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):
这样就实现了在一个 Buffer 中存储全部顶点信息。值得注意的是,目前我们存储的顶点信息都是给 vertex shader 使用的,如果想要给 fragment shader 传递变量,例如顶点颜色,该如何使用呢?
shader 间传递变量
从 vertex shader 向 fragment shader 传递变量需要使用 varying
,为啥不是熟悉的 attribute
而需要这个额外的看起来像桥梁一样的新东西呢?
要弄清这个问题,我们得知道 shader 间传值并不是这么简单的,中间会经历两个步骤:
- geometric shape assembly process: In this stage, the geometric shape is assem- bled from the specified vertex coordinates. The first argument of gl.drawArray() specifies which type of shape should be assembled.
- rasterization process: In this stage, the geometric shape assembled in the geometric assembly process is converted into fragments.
可见在 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 坐标系,需要进行转换:
只需要反转一下 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.TEXTURE0
到 gl.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) {}
指定纹理参数
完成了纹理对象的创建,激活和绑定,随后需要设置一些纹理参数。这些参数指定了在某些场景下纹理的规则:
- gl.TEXTURE_MAG_FILTER 当纹理应用在大尺寸形状上时,例如 1x1 的纹理放大到 2x2。需要指定这些多出来的像素如何计算。算法有两种:gl.NEAREST 和 gl.LINEAR。
- gl.TEXTURE_MIN_FILTER 缩小纹理。算法同上。
- gl.TEXTURE_WRAP_S/T 纹理四周填充策略。例如默认值是重复拼贴 gl.REPEAT,有点类似 CSS 中
background-image: repeat-x/y;
。使用边缘像素填充造成拖影效果 CLAMP_TO_EDGE。
「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 就多了四种选择,也很好理解:
- gl.NEAREST_MIPMAP_NEAREST 会选择最近的 mipmap level,然后在这个 level 下纹理中应用 gl.NEAREST 策略。
- gl.NEAREST_MIPMAP_LINEAR
- gl.LINEAR_MIPMAP_NEAREST
- gl.LINEAR_MIPMAP_LINEAR
这里不得不吐槽下 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;
}