大家好,我是前端西瓜哥。之前讲解了如何用 WebGL 绘制红色三角形,今天西瓜哥带大家来学习如何将图片绘制到画布上的技术:纹理映射(texture mapping)。
纹理映射会根据纹理图像,将光栅化后的每个片元(像素点)设置对应颜色值。这些像素也称为 纹素(texels, texture elements)。
纹理坐标
纹理图像的坐标系统是二维的,为和世界坐标的 x、y 区分,WebGL 对应使用 s、t 来表示。
目前纹理坐标更常用的命名是 uv。因为历史原因,WebGL 还是用的 st。
和世界坐标系类似,宽高使用的是一个比例值,即真实像素位置除以宽高后得到的比例。
着色器
const vertexShaderSrc = `
attribute vec4 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
gl_Position = a_Position;
v_TexCoord = a_TexCoord;
}
`;
const fragmentShaderSrc = `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
void main() {
gl_FragColor = texture2D(u_Sampler, v_TexCoord);
}
`;
相比绘制单色三角形,我们在顶点着色器加了 a_TexCoord,记录顶点对应的纹理坐标,因为纹理坐标只有两个维度,所以用的 vec2 属性。
并命名一个 v_TexCoord 的 varying 变量,用于将 a_TexCoord 的值传递给片元着色器。
片元着色器,声明了一个接收同名 v_TexCoord 变量 接收传过来的纹理坐标。
u_Sampler 是 sampler2D 类型,是一个二维纹理采样器,指定着色器提取颜色的纹理对象。texture2D(u_Sampler, v_TexCoord) 表示从 u_Sampler 纹理采样器中的某个位置中取出颜色。
传入顶点数据
将顶点位置和纹理位置对应好,放在一个缓冲区中,并设置读取规则。
先读第一个点的位置,然后是第一个点对应的纹理坐标。然后第二个点...
// 顶点坐标,纹理坐标
const verticesTexCoords = new Float32Array([
// 左上点。左边两个是顶点,右边两个是纹理
-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,
]);
const FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;
// 创建缓存对象
const verticesTexBuffer = gl.createBuffer();
// 绑定缓存对象到上下文
gl.bindBuffer(gl.ARRAY_BUFFER, verticesTexBuffer);
// 向缓存区写入数据
gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);
// 获取 a_Position 变量地址
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
// 将缓冲区对象分配给 a_Position 变量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
// 允许访问缓存区
gl.enableVertexAttribArray(a_Position);
// 传入纹理坐标位置信息
const a_TexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2);
gl.enableVertexAttribArray(a_TexCoord);
纹理对象与图片绑定
/***** 纹理对象 *****/
const texture = gl.createTexture(); // 创建纹理对象
const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler'); // 获取 u_Sampler 地址
const img = new Image();
img.onload = () => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 翻转纹理图像的 y 轴
gl.activeTexture(gl.TEXTURE0); // 开启 0 号纹理单元
gl.bindTexture(gl.TEXTURE_2D, texture); // 将我们的纹理对象绑定到 gl.TEXTURE_2D,类似绑定缓冲区对象
// 配置纹理参数
// 这里表示在 “绘制范围小于纹理尺寸” 时,使用 “加权平均” 算法缩小
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// 将纹理图像分配给纹理对象
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
// 使用 0 号纹理单元
gl.uniform1i(u_Sampler, 0);
/****** 绘制 ******/
// 清空画布,并指定颜色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制矩形,这里提供了 4 个点
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
img.src = './fe_watermelon.jpg';
创建纹理对象,然后在图片加载完之后配置到纹理对象上。
WebGL 下有多个纹理纹理单元(比如 gl.TEXTURE0、gl.TEXTURE5 之类),至少有 8 个。这些单元可以保存多个我们创建好的纹理图片,在需要的时候进行切换。
激活一个纹理单元:
gl.activeTexture(gl.TEXTURE0);
激活后,我们用 gl.TEXTURE_2D 来访问这个纹理图像,进行纹理绑定和参数配置。
这里我们需要反转纹理图像的 y 轴线,因为图片和纹理坐标系不一样。我实在是蚌不住了。
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 翻转纹理图像的 y 轴
gl.texImage2D 用于将纹理图像分配给纹理对象。
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
gl.RGB 表示纹素的格式为 RGB,此外还有 gl.RGBA、gl.LUMINANCE(流明) 等。gl.UNSIGNED_BYTE 表示纹理的数据类型。
将纹理单元绑定到 u_Sampler 变量上。
gl.uniform1i(u_Sampler, 0);
最后就是调用 el.drawArrays 方法进行绘制。
图片的注意事项
关于图片,有几点需要注意。
首先是 图片不要跨域,因为安全限制,Canvas 是不能将跨域的图片绘制上去的,会报错。
然后是 图片的尺寸需要是 2 的幂次方,比如 16、32、64、128、256、512。
尺寸不对的图片需要留白补全到 2 的幂次方,然后在设置纹理坐标时指定对应真正宽高比例。
完整源码
/** @type {HTMLCanvasElement} */
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
const vertexShaderSrc = `
attribute vec4 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
gl_Position = a_Position;
v_TexCoord = a_TexCoord;
}
`;
const fragmentShaderSrc = `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
void main() {
gl_FragColor = texture2D(u_Sampler, v_TexCoord);
}
`;
/**** 渲染器生成处理 ****/
// 创建顶点渲染器
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSrc);
gl.compileShader(vertexShader);
// 创建片元渲染器
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSrc);
gl.compileShader(fragmentShader);
// 程序对象
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
gl.program = program;
// 顶点坐标,纹理坐标
const verticesTexCoords = new Float32Array([
// 左上点。左边两个是顶点,右边两个是纹理
-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,
]);
const FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;
// 创建缓存对象
const verticesTexBuffer = gl.createBuffer();
// 绑定缓存对象到上下文
gl.bindBuffer(gl.ARRAY_BUFFER, verticesTexBuffer);
// 向缓存区写入数据
gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);
// 获取 a_Position 变量地址
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
// 将缓冲区对象分配给 a_Position 变量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
// 允许访问缓存区
gl.enableVertexAttribArray(a_Position);
// 传入纹理坐标位置信息
const a_TexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2);
gl.enableVertexAttribArray(a_TexCoord);
/***** 纹理对象 *****/
const texture = gl.createTexture(); // 创建纹理对象
const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler'); // 获取 u_Sampler 地址
// 记载图片
const img = new Image();
img.onload = () => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 翻转纹理图像的 y 轴
gl.activeTexture(gl.TEXTURE0); // 开启 0 号纹理单元
gl.bindTexture(gl.TEXTURE_2D, texture); // 将我们的材质对象绑定上去
// 配置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// 配置纹理图像
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
// 绑定 0 号纹理单元
gl.uniform1i(u_Sampler, 0);
/****** 绘制 ******/
// 清空画布,并指定颜色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制矩形,这里提供了 4 个点
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
img.src = './fe_watermelon.jpg';
绘制结果:
填充方式
补充一下设置图片的几种方式。
gl.texParameteri(target, pname, param);
上面表示,在 pname 场景下,使用 param 策略。比如
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
表示在 “绘制范围小于纹理尺寸”(gl.TEXTURE_MIN_FILTER) 场景下,使用 “加权平均”(gl.LINEAR) 算法进行缩小。
参数 pname 有下面几个几个值可选择:
- gl.TEXTURE_MAG_FILTER:纹理放大。对应场景为纹理尺寸小于要绘制区域,需要将纹理放大。默认值为 gl.LINEAR
- gl.TEXTURE_MIN_FILTER:纹理缩小。默认值为 gl.NEAREST_MIPMAP_LINEAR。
- gl.TEXTURE_WRAP_S:纹理水平填充。默认为 gl.REPEAT。
- gl.TEXTURE_WRAP_T:纹理垂直填充。默认为 gl.REPEAT。
参数 param 的一些可选值:
- gl.LINEAR:使用 “加权平均” 缩放;
- gl.NEAREST:使用 “曼哈顿距离” 缩放;
- gl.REPEAT:重复平铺;
- gl.MIRRORED_REPEAT:镜像重复平铺:
- gl.CLAMP_TO_EDGE:使用纹理图像的边缘进行延伸填充;
看个实例,将绘制区域设置为图片的 2.5 倍,并设置填充方式。
// 顶点坐标,纹理坐标
const verticesTexCoords = new Float32Array([
// 左上点。左边两个是顶点,右边两个是纹理
-0.5, 0.5, 0.0, 2.5,
// 左下
-0.5, -0.5, 0.0, 0.0,
// 右上
0.5, 0.5, 2.5, 2.5,
// 右下
0.5, -0.5, 2.5, 0.0,
]);
// ...
// 配置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// 边缘像素平铺
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
得到了一个很有意思的结果: