原文首发于简书,今天试了一下掘金的文章编辑器,简直好用!以后文章就都发表在掘金了。。。
自从2017年2月份,写了一个基于canvas2d的字符串动画的玩具之后,就一直想着怎么样把那个玩具性能优化一下。而且那玩意局限性很大,只能渲染纯色单色的字,而且通过每一帧疯狂调用CanvasRenderingContext2d.fillText
方法,导致绘制效率十分低下,非常吃cpu资源,cpu不好的话,非常容易卡顿。
- 编写顶点着色器和片元着色器的代码
- 引入着色器代码并且让浏览器去编译执行它们
- 给着色器程序传值
- 使用webgl绘制图片
- 使用点精灵
注意哦,webGL!== 3d。webGL只是个底层的绘制API,我仅仅是使用webGL去绘制2d的内容,所有操作均不依赖其他框架,跟threejs无关,跟babylonjs无关,仅仅是个原生wegGL练习。
,ios12以下不支持getUserMedia
,andorid x5内核存在canvas绘制video画面卡顿的bug(据说是尚不支持webGL视频纹理)。
预研
- 原理 实际上,这个玩意也就是万恶的马赛克的升级版而已,马赛克大家都很熟悉,最简单的做法,在原图像上先画一片小正方形,取小正方形的中点,读取该点在图像上的颜色值,然后再给这个小正方形涂上这个颜色,然后几片正方形组合起来,就是一个马赛克区域啦。而我们要做的只是在最后的阶段,涂色的时候做一点改变,把纯颜色填充变为文字纹理填充,然后就变成了效果图的样子。
- 点精灵 实际上就是利用片元着色器绘制点。如果你有看过一些webGL的入门教程,第一章的例子通常是画那个三个点组成的三角形,但实际上,你是可以让webGL只填充三个顶点的颜色而不去给三角形填充颜色的,只需要改一下参数,改成如下的代码:
gl.drawArray(gl.POINTS,0,3)复制代码
只需要在gl.drawArray
方法的第一个参数传入gl.POINTS
常量,就能开启点精灵绘制,片元着色器也只为顶点上色。利用这个特性我们可以简单绘制马赛克。 相比使用canvas2d的fillRect方法绘制正方形,点精灵可以一次性绘制上千个正方形,而且你可以在片元着色器内,在正方形内部填充不同的颜色或者图案。 大家可以访问这个感受一下 或者拿出手机扫一扫: 如果大家感兴趣,这部分以后也可以单独拎出来讲一讲23333
开始编写顶点着色器
现在开始动手写代码了,首先是编写顶点着色器的代码。顶点着色器的作用很简单,就是确定点精灵的位置和大小用的,不过,因为webGL里面的坐标系跟我们平常在网页开发里面的坐标系不一样,我们平常用的什么offsetLeft或者offsetTop,都是相对左上角原点去算的。而wegGL的原点是在图像的中间且y轴是反过来的,因此在顶点着色器里面我们还要翻转一下坐标,方便后续js的计算。
precision mediump float;// 设置浮点精度:中attribute vec2 a_position;// 点精灵位置uniform vec2 u_resolution;// canvas的宽高uniform float u_size; // 点精灵大小varying vec2 v_position; //将点精灵的位置传递给片元着色器void main(){ // 从像素坐标转换到 [0.0,1.0]这个区间内 vec2 st = a_position / u_resolution; // 然后再把[0.0,1.0]映射到[-1.0,1.0]这个区间内,然后y轴翻转 vec2 position = (2.0 * st - 1.0) * vec2(1,-1); // 把st丢给片元着色器,图像采样要用到 v_position = st; // 确定点的大小 gl_PointSize=u_size; // 确定点的位置 gl_Position=vec4(position,0.0,1.0);}复制代码
着色器实际上就是一个函数,逻辑也不复杂,语法也简单。对于前端来说,需要注意的是类型问题,还有就是一行代码结尾一定要带分号,不然webGL分分钟给你罢工。
开始编写片元着色器
比较复杂的就是片元着色器了,虽说它的工作就是确定像素点的颜色值,但是涉及到两个纹理:视频纹理与文字纹理的处理。
视频纹理的处理很简单,我们只需要拿到顶点着色器丢过来的那个st坐标点,获得视频纹理在这个坐标点的颜色就可以了。
而文字纹理就不一样了,因为我是通过将一长串文字用绘制在一个canvas上,然后直接把这个canvas当成纹理丢进片元着色器。因此,在片元着色器里面,我们需要确定当前绘制的点精灵要使用这一长串文字中的哪一个,然后把这个字裁剪出来。
那么,片元着色器里面究竟要用一长串文字中的哪一个?这个我们可以根据颜色灰度来决定,第一个字代表白色,最后一个字代表黑色,然后中间那些字对应各个阶段的灰度值,这个规则是沿用之前的做法,只不过,之前是使用js来判断使用哪个字,在这里,我们将判断权交给webGL,交个片元着色器,让webGL的glsl语言来判断。
precision mediump float; // 设置浮点精度:中uniform sampler2D u_tex1; // 视频纹理(一个video)uniform sampler2D u_tex2; // 文字纹理(一个canvas)uniform vec2 u_resolution; // canvas的宽高uniform float u_len; // 文字的数量varying vec2 v_position; // 点精灵的坐标void main(){ // 点精灵对应在视频纹理里面的颜色 vec4 color = texture2D(u_tex1 , v_position); // 算一下color的灰度,用来决定用哪一个字 float gray = (color.r + color.g + color.b)/3.0; // 算一下,一个字在文字纹理里面有多宽 float s = 1.0/u_len; // 根据灰度,和字体宽度,算一下我们要的那个字从文字纹理里面的第几个像素开始 // 因为字数肯定是整数,这里需要使用floor函数来丢掉小数部分 // 然后算出是第几个字然后再乘以字体宽度,得到我们要的字在文字纹理的位置 float p = floor((1.0-gray)/s)*s; // 从文字纹理拿字 vec4 text_color = texture2D(u_tex2,vec2( gl_PointCoord.x/u_len + p, gl_PointCoord.y )); // 记录一下我们拿到的文字纹理的alpha通道 float alpha = text_color.a; // 输出颜色,让有笔画的部分着色,没有笔画的透明 gl_FragColor = vec4(color.rgb,alpha);}复制代码
着色器跟C差不多,语法上真的不难。
难的是,你要如何确定每一个像素点的颜色。 包括以后要学习的3D部分,也是同样的道理。引入着色器代码并编译
下面基本是教科书式的代码 首先是用webGL创建一个着色器程序对象,为顶点着色器和片元着色器的连接做准备。
var cvs = document.getElementById("cvs");var gl = cvs.getContext("webgl");var progarm = gl.createProgram();复制代码
接着是创建顶点着色器和片元作色器,把上面的着色器源码拿给浏览器去编译。
//创建一个顶点着色器对象var vShader = gl.createShader(gl.VERTEX_SHADER);//将顶点着色器的源码怼进去gl.shaderSource(vShader,`假装是上面的顶点作色器源码`);//然后开始编译源码gl.compileShader(vShader);//编译完之后,叫上面的着色器程序对象过来收货gl.attachShader(program,vShader);//然后创建片元着色器对象var fShader = gl.createShader(gl.FRAGMENT_SHADER);gl.shaderSource(fShader,`假装是上面的片元作色器源码`);gl.complieShader(fShader);gl.attachShader(program,fShader);复制代码
当program
对象收到顶点片元两个着色器之后,就可以帮这两个着色器连接起来。之前的顶点着色器里面说到把st
变量丢给片元着色器,这个就是program
对象帮忙丢的。
// 连接起两个程序gl.linkProgram(program);//然后跟webgl说,我要使用这个程序gl.useProgram(program);复制代码
这个过程非常繁琐,我们可以封装一下,方便使用与记忆
/*** @name createProgram* @desc 创建着色器程序* @param {WebGLRenderingContext} gl - webGl的context* @param {String} vsource - 顶点着色器源码字符串* @param {String} fsource - 片元着色器源码字符串* @return {WebGLProgram} - 着色器程序对象*/function createProgram(gl,vsource,fsource){ const program = gl.createProgram(); const createShader = (source,type)=>{ const shader = gl.createShader(type); gl.shaderSource(shader,source); gl.compileShader(shader); gl.attachShader(program,shader); return shader; } createShader(vsource,gl.VERTEX_SHADER); createShader(fsource,gl.FRAGMENT_SHADER); gl.linkProgram(program ); return program ;}//使用var cvs =document.createElement("canvas");var gl = cvs.getContext("webgl");var program = createProgram( gl, `假装是顶点着色器源码`, `假装是片元着色器源码`);gl.useProgram(program )复制代码
这样使用就简单多了。
创建文字纹理与视频纹理
关于纹理的创建以及一些小问题,之前的文章《》里面多多少少有涉及,大家可以参考看一下,这里我就直接贴代码了。 创建纹理的方法封装:
/**创建纹理贴图 * @param {WebGLRenderingContext} webgl - 使用webgl的上下文 * @param {Canvas||Image} image - 要作为纹理的图片对象 * @return {WebglTexture} texture对象 */ function createTexByImage(webgl, image) { var texture = webgl.createTexture(); webgl.bindTexture(webgl.TEXTURE_2D, texture); webgl.texImage2D( webgl.TEXTURE_2D, 0, webgl.RGBA, webgl.RGBA, webgl.UNSIGNED_BYTE, image ); if (isPowerOf2(image.width) && isPowerOf2(image.height)) { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); return texture } webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_MIN_FILTER, webgl.NEAREST); webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_MAG_FILTER, webgl.NEAREST); webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_WRAP_S, webgl.CLAMP_TO_EDGE); webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_WRAP_T, webgl.CLAMP_TO_EDGE); return texture } /**检查数字是否为2的指数 * @param {Number} value - 要检查的值 * @return {Boolean} */ function isPowerOf2(value) { return !(value & (value - 1)); }复制代码
然后使用的话,就直接createTexByImage(gl,image);
传入canvas/image/video创建纹理。
-
文字纹理canvas的绘制 首先用canvas2D画出32*32的格子,然后把文字
fillText
进去,只要就能保证字体宽度相等,不然中文与英文字母混编的话,字体不统一,在glsl
里面就非常难计算/** * 创建文字纹理 * @param {String} text - 要成为纹理的文字 * @param {String} fontFamily - 文字的字体 * @return {HTMLCanvasElement} */ function createTextTextrue(text, fontFamily) { var cvs = document.createElement("canvas"); var ctx = cvs.getContext("2d"); cvs.width = 32 * text.length; cvs.height = 32; ctx.font = "32px " + fontFamily; ctx.textAlign = "center"; ctx.textBaseline = "middle"; text.split("").forEach(function(word, i) { ctx.fillText(word, i * 32 + 16, 16); }); return cvs; }复制代码
结合上面的创建纹理的函数,我们就可以这样使用:
createTexture(gl,createTextTextrue('文字','微软雅黑'))复制代码
一个文字纹理就被创建出来准备给webGL用了。
-
视频纹理,直接用上面的函数,
createTexure(gl,video)
把video传进去就可以了。只不过有一点要注意,传入的时候video要处于有画面的状态,如果video尚未播放,传进去会报错。
采样点
采样点也就是那些顶点的坐标,知道canvas的尺寸,以及字体的大小,然后就可以生成坐标了。
因为数据也简单,就只有x,y值,所以,给webGL传值可以说相当容易了。 我们直接用一个buffer传过去/**创建采样点 */ function createSampPoints(width, height, step) { var a = []; for (var i = 0; i <= height; i += step) { for (var j = 0; j <= width; j += step) { a.push(j, i); } } return a; } // 创建顶点 var points = new Float32Array(createSampPoints( cvs.width, cvs.height, 32 )); //创建buffer var buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); //将顶点写入内存 gl.bufferData(gl.ARRAY_BUFFER, points , gl.STATIC_DRAW); // 获取a_position的内存地址 var index = gl.getAttribLocation(program,'a_position'), // 激活a_position gl.enableVertexAttribArray(index); // 往a_position写值(规定a_position读取buffer的规则) // 读两个点,float类型,不需要归一化,两次点集相隔0,从0位开始读取 gl.vertexAttribPointer(index,2, gl.FLOAT, false, 0, 0)复制代码
这样着色器里面就能够读到a_position的值了,也就是我们丢过去的采样点。
绘制
还是老样子,先使用getUserMedia
读到视频流,然后让video播放它。
而webGL这边,可以开一个requestAnimationFrame动画,不断查询video的播放状态和上面那些操作是否就绪,如果符合条件的话就开始绘制,不符合的话就跳过。还有就是,因为我这边是通过ajax来请求两个着色器的源码的,所以视频开始播放的时候,可能我ajax请求还在路上,所以根本没法监听video的play事件,只能疯狂轮询了。如果你能确定上面那些操作在视频开始播放的时候就已经就绪了,可以大胆地监听play事件。
绘制的话,因为视频画面会更新的缘故,所以每一帧你都需要更新一下视频纹理,但是这里千万要注意的是,更新纹理不是创建纹理!!!,千万别在requestAnimationFrame调用gl.createTexture
方法,每一帧都创建纹理对内存的消耗远远大于GC的收集速度,进而导致内存泄漏。正确的做法是,找到之前那个视频纹理,重新激活它,然后使用gl.texImage2D
方法去更新纹理。
function draw(){ if(/**判断一下是否可以绘制*/){ requestAnimationFrame(draw); //直接下一帧 return } // u_tex0代表的是视频纹理,所以我们激活一下TEXTURE0 gl.activeTexture(gl.TEXTURE0); // 假设videoTexture是之前通过createTexture创建出来的纹理 // 这里的绑定是绑上面的TEXTURE0纹理,将videoTexture重新赋值给它 gl.bindTexture(gl.TEXTURE_2D, videoTexture); // 将视频当前帧传入进去 gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video ); // 清画面 gl.clear(gl.COLOR_BUFFER_BIT); // 绘制 gl.drawArrays( gl.POINTS, 0, pointes.length / 2 ); requestAnimationFrame(draw);}复制代码
同样的对文字纹理的更新也遵循此办法。 绘制的事情几乎与js无关,js在这里面的作用就是,配置好一切、更新纹理,然后调用绘制而已,对cpu的开销也小,绘制过程中连一次循环什么的都不需要,最主要的,是在移动端的表现相当流畅,webGL这种技术简直跟亲妈一样强大。
结语
请各位同学千万别问这玩意在现实中有什么用,能实现什么需求。看标题,这个只是个练习而已,仅仅是为了好玩。 不然你打开《webgl编程指南 》这本书,每个例子都是画三角形,画三角形,我画到现在对三角形有阴影了。。。 嘛,本来学习就是一件枯燥的事情(对我这种学渣来说),如果不在这个过程中找到乐趣所在,很容易就放弃的。多多利用学到的知识,再结合以前学到的,去写一些有趣的练习吧,举一反三,这样对知识的理解或更深刻。 况且在这个练习里面,遇上了内存泄漏的问题并且解决掉了它,可谓是意外之喜呢。毕竟书里没写这部分的内容对不对,遇到就是赚到2333
参考
- [1]
- [2]
- [3]