摘要
上一篇文章介绍了webgl里三角形的绘制,以及利用纹理如何绘制图片。这篇文章将引入另一个webgl的功能——离屏渲染,以及利用离屏渲染做拾取判断。
离屏渲染
- 首先,canvas之所以能绘制出图片,本质是执行了webgl程序后,往缓存中储存了颜色深度等信息,再把这部分数据渲染到页面的canvas里,而这个缓存,实际上是一个叫帧缓冲的对象
(FBO, FramBuffer Object),这个帧缓冲对象会默认被绘制到页面的canvas中; - 而这个帧缓冲,除了webgl默认自带的这个,也可以自己另外创建,并且可以在其上执行shader程序,将gpu的运行结果储存进去,同时这个自己创建的帧缓冲,并不会渲染到页面上,所以也叫离屏渲染;
- 创建帧缓冲的方法就是
gl.createFramebuffer
帧缓冲对象
-
帧缓冲一般包括以下三个
关联对象(attachments):颜色关联对象 (color attachment)深度关联对象 (depth attachment)模版关联对象 (stencil attachment)
-
color attachment储存的是颜色相关的信息,可以理解为整个帧缓存对象就是一张纹理,这个纹理的颜色就保存在color attachment中 -
depth attachment储存的是深度相关的信息,其影响绘制图案的层级,以及绘制阴影的绘制,要想深度关联对象起效,需要开启深度测试
gl.enable(gl.DEPTH_TEST);
stencil attachment,模版关联对象,这个模版其实理解为筛子更贴切,其控制,经过颜色关联以及深度关联后生成的图案,有哪些可以被绘制到帧缓冲里面,简单可以理解为,模版里储存了0或1的数据,与其余关联对象重叠,0的不绘制,1的绘制(参考)- 这里我们只谈论颜色关联对象,因为他最常用到;
帧缓冲对象有什么用
- 假设我们要实现一个功能,在鼠标滑过canvas时,显示局部的预览图,我们可以怎么实现?
- 一种方法是,通过
gl.readPixels读取canvas上某一块图片范围的像素,然后将这个像素集合生成一个图片,再把它导入图片中,最后渲染出来; - 另外一种方法就是离屏渲染,通过计算鼠标与图片的相对位置,可以利用离屏渲染,在帧缓冲中绘制局部图像,最后将与之绑定的纹理叠加渲染到原图之上,就实现了在原图的某个区域内显示局部放大了的图片
局部渲染实现
- shader沿用上一篇笔记中的代码,如下
<script id="vertex-shader-2d" type="notjs">
attribute vec2 a_position;
uniform mat4 u_proj;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main(void) {
gl_Position = u_proj * vec4(a_position, 0, 1.0);
v_texCoord = a_texCoord;
}
</script>
<script id="fragment-shader-2d" type="notjs">
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_image;
void main() {
vec4 texture = texture2D(u_image, v_texCoord);
gl_FragColor = texture;
}
</script>
- 绘制图片等流程都与上一篇笔记一致,先创建一个纹理,再把纹理和shader中的纹理变量关联,然后将图片赋予该纹理,最后确定坐标,运行shader
- 这里主要记录一下,如何创建一个帧缓冲对象,并将空纹理与之绑定,最终将图片绘制到该空纹理中
- 首先创建一个帧缓冲对象
const framebuffer = gl.createFramebuffer();
- 绑定纹理
function attachTexture(width, height, framebuffer, level) {
// 创建纹理
const texture = gl.createTexture();
// 绑定为当前操作对象
gl.bindTexture(gl.TEXTURE_2D, texture);
// 纹理参数
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);
// 设置纹理的存储格式,长宽等规格
gl.texImage2D(gl.TEXTURE_2D, level, gl.RGBA, width, height, level, gl.RGBA, gl.UNSIGNED_BYTE, null);
// 设置当前的缓冲操作对象为自定义的帧缓冲对象
// 如果这里framebuffer = null,就相当于当前操作的是绘制缓冲,将被输出到画面之上
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
// 绑定纹理到当前的缓冲对象上
// COLOR_ATTACHMENT0是颜色关联对象
// DEPTH_ATTACHMENT是深度关联对象
// STENCIL_ATTACHMENT是模版关联对象
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, level);
return texture;
}
const aTexture = attachTexture(gl.canvas.width, gl.canvas.height, framebuffer, 0);
- 注意,这里绑定纹理的时候,顺便生成了一个新的纹理,纹理的长宽是canvas的长宽,这里主要是为了绘制的时候能沿用绘制的坐标,实际你可以设置任意的长宽,但需要注意将该纹理重新绘制到canvas商的时候,有可能需要进行长宽的变换
- 另外,纹理越大,存储的信息就越多,为了绘制放大图片的时候,能得到一个较为清晰图案,可以适当的等比放大新建纹理的长宽,放大后,绘制坐标需相应改变,这里为了简便,使帧缓冲纹理与绘制纹理大小一致
- 参数level是用来设置纹理细致程度的,普通纹理给0即可
- 定义绘制函数
// 预览框的宽
const previewW = 60;
// 预览框的高
const previewH = 120;
const { clientWidth, clientHeight } = canvas;
function drawImage (framebuffer, array, texCoord) {
// 绑定当前操作的帧缓冲
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
// 绘制之前需要设置一次viewport
if (framebuffer) {
gl.viewport(0, 0, previewW, previewH);
gl.clearColor(0, 0, 0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
} else {
gl.viewport(0, 0, clientWidth, clientHeight);
}
// 顶点数据
const posData = new Float32Array(array);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, posData, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// 纹理坐标数据
const texData = new Float32Array(texCoord);
const texBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texData, gl.STATIC_DRAW);
const aTex = gl.getAttribLocation(program, 'a_texCoord');
gl.enableVertexAttribArray(aTex);
gl.vertexAttribPointer(aTex, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
- 这里我们定义了一个绘制函数,无论是原图的渲染,还是局部图的放大,都将调用这个函数,他接收的参数有
framebuffer: 绘制在哪个缓冲帧上,如果为null,则会直接渲染在canvas上array: 绘制在画布上的坐标texCoord: 绘制图案的纹理坐标
- 注意到这里有个特殊处理
gl.viewport,该函数可以自动将绘制坐标和实际展示的坐标进行转换,省去了自己计算的麻烦;简单的说,就是在我们生产局部预览图案的纹理时,我们先按照canvas的宽高进行绘制,然后利用gl.viewport,可以将绘制的图案缩小到我们定义的纹理宽高上进行保存,从而生成我们期望得到的纹理 - 接下来我们监听鼠标的移动,进行图片的绘制
// 预览倍数
const previewScale = 1.2;
// 预览图在原图上的实际尺寸
const previewSize = {
width: previewW / previewScale,
height: previewH / previewScale
};
// 绘制到帧缓冲上时,使用的绘制坐标
// 直接按canvas的宽高进行绘制,后面通过viewport会将图案变换成对应的纹理宽高
const previewDrawArr = [
0, 0,
clientWidth, 0,
0, clientHeight,
clientWidth, clientHeight
];
// 绘制局部预览图时使用的纹理坐标
const previewDrawTexCoord = [
0, 1,
1, 1,
0, 0,
1, 0,
];
canvas.addEventListener('mousemove', (e) => {
// img为image元素,存储图片信息
if (!img || !img.src) return;
// 获取鼠标在屏幕上的点
let { clientX, clientY } = e;
// 限定坐标的边界
if (clientX - previewSize.width / 2 < 0) clientX = previewSize.width / 2;
else if (clientX + previewSize.width / 2 > imgW) clientX = imgW - previewSize.width / 2;
if (clientY - previewSize.height / 2 < 0) clientY = previewSize.height / 2;
else if (clientY + previewSize.height / 2 > imgH) clientY = imgH - previewSize.height / 2;
// 将鼠标坐标转换为纹理坐标
// 纹理坐标范围是0~1,所以要除以宽高
// The texture coordinate (0, 0) does not refer to the top-left or bottom-left pixel of a texture, it refers to the first pixel of a texture. Similarly (1, 1) does not refer to the bottom-right or top-right pixel but rather to the last pixel
// https://stackoverflow.com/questions/48124001/opengl-texture-coordinates-are-opposite-when-reading-from-a-framebuffer-object
const previewTexCoord = [
(clientX - previewSize.width / 2) / imgW, (clientY - previewSize.height / 2) / imgH,
(clientX + previewSize.width / 2) / imgW, (clientY - previewSize.height / 2) / imgH,
(clientX - previewSize.width / 2) / imgW, (clientY + previewSize.height / 2) / imgH,
(clientX + previewSize.width / 2) / imgW, (clientY + previewSize.height / 2) / imgH,
];
// 使用原图
gl.bindTexture(gl.TEXTURE_2D, texture);
// 截取原图中的一部分生成纹理
drawImage(framebuffer, previewDrawArr, previewTexCoord);
// 先将原图绘制在canvas上
drawImage(null, positionArr, texCoord);
// 切换成帧缓冲绑定的纹理
gl.bindTexture(gl.TEXTURE_2D, aTexture);
// 将帧缓冲中的图片绘制到canvas上,实现局部预览
drawImage(null, previewPosArr, previewDrawTexCoord);
});
- 在监听鼠标移动的过程中,我们限定了坐标的边界,这是因为如果不限制,算出来的纹理坐标将会存在负数,而如果是负数的纹理坐标,绘制过程中,将会对边界进行拉伸或重复处理,具体的处理与我们定义纹理时,通过
gl.texParameteri设置的纹理参数有关 - 另外,注意我们定义局部预览图案对应的纹理坐标时,截取坐标与绘制坐标的区别;按上一章的内容,因为图片的储存格式是从上往下,从左往右,所以图案的y轴是从上往下的,但一旦我们通过绘制将图案保存为纹理之后,这个纹理内的坐标将按照webgl内定义的坐标进行保存,即左下角为原点,这也是为什么
previewTexCoord是按照y轴从上往下为正向进行取值,而previewDrawTexCoord是按照y轴从下往上为正向进行取值的,参考 - 接下来运行一下看看效果
- demo源码
拾取判断
- 接下来,我们来实现拾取判断,在实现该判断前,我们先要有一个方法,来分别给每个图像分配一个唯一id,由于在webgl上,所有数据都将被绘制成像素,那我们也可以利用一个独特的像素值来标记这个图案
- 这里为了简便,我们考虑一个像素有
rgba4个值,这里我们令a恒定为1,以区分背景色,同时rgb各等于0和1,这样我们就可以标记8个图形,足够演示了 - 当然你也可以通过更复杂的算法,去计算每个图像独特的像素值,由于一个像素值可以通过8位字节表示,理论上可以区分2的32次方(rgba四个数,共32个字节)种组合
- 接下来改造一下上面的程序,先新增一个
fragment shader,用来给帧缓冲绘制包含id信息的颜色数据
<script id="fragment-shader-2d-pick" type="notjs">
precision mediump float;
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
</script>
// 新增一个绘制program
const pickFragShaderSource = window.document.querySelector('#fragment-shader-2d-pick').innerHTML;
const pickFragShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(pickFragShader, pickFragShaderSource);
gl.compileShader(pickFragShader);
const pickProgram = gl.createProgram();
// vertexShader就是原来的顶点着色器
gl.attachShader(pickProgram, vertexShader);
gl.attachShader(pickProgram, pickFragShader);
gl.linkProgram(pickProgram);
- 接着,允许选择多张图片,并按固定大小展示在canvas的四个角
<input id="input" type="file" accept="image/webp,image/png,image/jpeg" multiple></input>
input.addEventListener('change', (e) => {
const files = e.target.files;
if (files && files.length) {
Array.prototype.slice.call(files, 0, 4).forEach((file, index) => {
// 最多渲染4张图
const _URL = window.URL || window.webkitURL;
const url = _URL.createObjectURL(file);
const { name } = file;
imgList.splice(0, imgList.length);
const img = new Image();
img.onload = () => onImgLoad(img, index, name);
img.src = url;
});
}
});
function onImgLoad (img, index, name) {
// 区分4个角的标记,也是后续id构造参数
const flag1 = index % 2 === 0;
const flag2 = index < 2;
// 四角坐标
const left = flag1 ? 0 : clientWidth / 2 + 30;
const right = flag1 ? clientWidth / 2 - 30 : clientWidth;
const top = flag2 ? 0 : clientHeight / 2 + 30;
const bottom = flag2 ? clientHeight / 2 - 30 : clientHeight;
const positionArr = [
left, top,
right, top,
left, bottom,
right, bottom
];
// 简单区分一下即可,4个数分别作为帧缓冲的rgba值
const uid = [flag1 ? 255 : 0, flag2 ? 255 : 0, 0, 255];
// 记录下来,后续用
imgList.push({
src: img,
posArr: positionArr,
uid,
name
});
// 这里的program是使用了texture的fragment shader,不是用于帧缓冲绘制的fragment shader
gl.useProgram(program);
const texture = createTexture(img, 0);
drawImage(positionArr, texCoord);
// 绘制缓冲帧
drawPick(framebuffer, positionArr, uid);
}
- 接着设置帧缓冲如何绘制
function drawPick (framebuffer, array, colorArr) {
// 保证当前使用的是pickProgram
gl.useProgram(pickProgram);
// 且绘制到帧缓冲中
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
// 顶点数据
const posData = new Float32Array(array);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, posData, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(pickProgram, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// 颜色uid
// 使用Uint8Array的目的是为了让输入的数值与后续readpixel出来的数值是一样的,否则,输入会默认已0~1的浮点数输入,输出是0~255的Uint8Array,无法直接对比
const uColor = gl.getUniformLocation(pickProgram, 'u_color');
gl.uniform4fv(uColor, new Uint8Array(colorArr));
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
- 最后与之前预览例子类似,监听鼠标移动,判断当前鼠标下的是哪一张图片
function drawImage (array, texCoord) {
// 绘制前,先确保绘制到canvas上,且使用的着色器是使用texture的
gl.useProgram(program);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
...
}
canvas.addEventListener('mousemove', (e) => {
if (!imgList.length) return;
let { clientX, clientY } = e;
render({ pick: true });
const pixels = new Uint8Array(4);
gl.readPixels(clientX, clientHeight - clientY, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const floatArr = Array.from(pixels)
// 对比图片uid,判断当前鼠标停留在哪张图上
// floatArr[3]区分背景色
const pick = imgList.filter(floatArr[3] && img => img.uid[0] === floatArr[0] && img.uid[1] === floatArr[1])[0];
if (pick) console.log(pick.name);
});
-
看看效果
-
这里需要注意的是,由于使用了两个片元着色器,生成了两个program,需要对两个着色器里的所有变量都做一次赋值,且赋值与绘制的时候,要区分好使用哪个program,否则会报错,另外也需要区分好是绘制到cnavas上还是离线屏上,着色器与缓冲是相互独立的,只是这里恰好一个一个缓冲使用一个着色器
平移与旋转矩阵
- 在之前文章里我们推导过坐标变换的矩阵,这里我们再看一下平移和旋转矩阵是怎么样的
平移矩阵
- 假设平移前坐标为
(X, Y),平移后得到的坐标为(X', Y'),可得
X' - X = dx
Y' - Y = dy
- 即
X + dx = X‘
Y + dy = Y’
- 平移前后的坐标可以表示为向量A,A‘
- 构造一个矩阵,使A乘矩阵等于A’,这个矩阵就是平移矩阵
旋转矩阵
- 设某点与原点连线和X轴夹角为b度,以原点为圆心,逆时针转过a度 , 原点与该点连线长度为R,
(X, Y)为变换前坐标,(X', Y')为变换后坐标,则有
X = R * cos(b)
Y = R * sin(b)
X' = R * cos(a + b) = R * cosa * cosb − R * sina * sinb
即:X' = X * cosa − Y * sina;
Y' = R * sin(a + b) = R * sina * cosb + R * cosa * sinb
即:Y' = X * sina + Y * cosa;
- 同理坐标可用向量代替,构造一个矩阵使上述等式成立,有
拾取优化
- 基于上述讨论,我们可以加入点击移动的功能,先改造一下顶点着色器,加入平移矩阵
<script id="vertex-shader-2d" type="notjs">
attribute vec2 a_position;
uniform mat4 u_proj;
// 平移矩阵
uniform mat4 u_translate;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main(void) {
// webgl矩阵乘法从右往左
// 顺序不能换,先平移后变换坐标,如果加入了旋转,则先旋转,后平移,最后坐标变换,顺序也不能变
gl_Position = u_proj * u_translate * vec4(a_position, 0, 1.0);
v_texCoord = a_texCoord;
}
</script>
const createTranslateMat = (tx, ty) => { // 创建平移矩阵
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
tx, ty, 0, 1
]
};
- 修改绘制的数据结构,新增平移数据
function onImgLoad (img, index, name) {
...
// 新增originClick,记录点击的坐标
// 新增translate,结合移动坐标计算实际移动距离
const imgInfo = {
src: img,
posArr: positionArr,
uid,
name,
originClick: { x: 0, y: 0 },
translate: { x: 0, y: 0 }
};
imgList.push(imgInfo);
...
}
- 修改绘制函数,主要添加平移矩阵的设置
function drawImage (info) {
const array = info.posArr;
...
// 平移矩阵
const transMat = createTranslateMat(info.translate.x, info.translate.y);
const uTrans = gl.getUniformLocation(program, 'u_translate');
gl.uniformMatrix4fv(uTrans, false, transMat);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
function drawPick (info) {
const array = info.posArr;
const colorArr = info.uid;
...
// 平移矩阵
const transMat = createTranslateMat(info.translate.x, info.translate.y);
// 注意这里从对应的program获取地址
const uTrans = gl.getUniformLocation(pickProgram, 'u_translate');
gl.uniformMatrix4fv(uTrans, false, transMat);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
function render (opt = { pick: true, canvas: true }) {
// 总的绘制函数
// 可分别绘制canvas或缓冲帧
imgList.forEach((img) => {
if (opt.canvas) {
gl.useProgram(program);
const texture = createTexture(img.src, 0);
drawImage(img.posArr, texCoord);
}
if (opt.pick) {
drawPick(framebuffer, img.posArr, img.uid);
}
});
}
- 最后修改监听函数,新增点击和松开鼠标的监听,在点击时判断选择哪个图片,松开时重置状态,移动时同步修改平移矩阵,重新绘制图片
// 用于记录选中哪个图片,由于图片信息是一个对象,这里获取到之后实际是引用地址,直接修改对原数据结构也会生效
let pick = undefined;
canvas.addEventListener('mousedown', (e) => {
if (!imgList.length) return;
let { clientX, clientY } = e;
// 选中不会产生移动,只判断离线屏即可
render({ pick: true });
const pixels = new Uint8Array(4);
// 判断鼠标点击选中的图片
gl.readPixels(clientX, clientHeight - clientY, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const floatArr = Array.from(pixels)
pick = imgList.filter(img => floatArr[3] && img.uid[0] === floatArr[0] && img.uid[1] === floatArr[1])[0];
if (pick) {
console.log('pick up:', pick.name);
// 再次点击时,可能已经产生移动,需要将已产生移动的距离扣减,下次再发生移动时,才会从所在位置开始移动,否则会回到原点再移动
// 而首次点击,由于初始值是0,所以也不会有影响
pick.originClick = { x: clientX - pick.translate.x, y: clientY - pick.translate.y };
}
});
canvas.addEventListener('mousemove', (e) => {
// 有选中才往下走
if (!pick) return;
let { clientX, clientY } = e;
const dx = clientX - pick.originClick.x;
const dy = clientY - pick.originClick.y;
pick.translate = { x: dx, y: dy };
// 移动不需要判断是否选中,只更新canvas即可
render({ canvas: true });
});
canvas.addEventListener('mouseup', (e) => {
// 松开时清除选择
pick = undefined;
});
- 另外还需要特别注意,由于这次我们的图片可以拖动,导致缓冲帧上的数据是被污染的,即拖动过程中,经过的像素点都被赋予了图片的uid颜色,而未被清除;根据webgl的文档,canvas上的图案,只管绘制,不管保存,并不会保留上一次绘制的数据,每一次draw都会被清除,清除时机由浏览器控制,所以无需手动清除(参考),实践也确实如此,但离线屏上的数据实践下来,发现是会被保留下来的,这点并没有找到相关文档说明,只能说实验结果如此,所以在离线屏的绘制之前,加上清除操作;
function clearBuffer (framebuffer) {
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.clearColor(0, 0, 0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
function render (opt = { pick: true, canvas: true }) {
clearBuffer(framebuffer);
...
}
- 看看最后的效果
写在最后
- 通过离屏渲染,可以对图案的整体或局部进行加工,并且随着功能的增加,需要用到不止一个program以及framebuffer,如何管理以及控制在对的时候,用对的program和framebuffer,是一个值得关注的问题,同时如果在开发过程中,发现canvas没有像预料的渲染,也可以先排查是不是这方面的问题;
- 最后,我们现在点击判断击中哪张图片的时候,是根据是否击中整个图片宽高内的四边形区域来进行判断的,如果这个图片是png格式,且他周围存在透明的像素,图案是个不规则的图形,是否能判断击中不透明区域才算选中这个图案呢,答案当然是可以的,只需要在离线屏渲染的时候,读取原texture,判断当前点是否为透明,再进行颜色绘制即可,具体流程就不展开了,有兴趣的朋友可以自己试试,这里附上实现了的参考。