笔者另外一篇文章 https://segmentfault.com/a/11... 讲了基于Canvas的文本编辑器“简诗”的实现,其中文字由WebGL渲染艺术效果,这篇文章主要讲述由Canvas获取字体数据、笔画分割解析、以及由WebGL进行效果渲染的过程。html
用canvas原生api能够很容易地绘制文字,可是原生api提供的文字效果美化功能十分有限。若是想要绘制除描边、渐变这些经常使用效果之外的艺术字,又不用耗时耗力专门制做字体库的话,利用WebGL进行渲染是一种不错的选择。c++
这篇文章主要讲述如何利用canvas原生api获取文字像素数据,并对其进行笔画分割、边缘查找、法线计算等处理,最后将这些信息传入着色器,实现基本的光照立体文字。git
利用canvas原生api获取文字像素信息的好处是,能够绘制任何浏览器支持的字体,而无需制做额外的字体文件;而缺陷是对一些高级需求(如笔画分割)的数据处理,时间复杂度较高。但对于我的项目而言,这是作出自定义艺术字效果比较快捷的方法。github
最后实现的效果:算法
本文的重点在于文字数据的处理,因此只用了比较简单的渲染效果,但有了这些数据,很容易设计出更为酷炫的文字艺术效果。canvas
“简诗”编辑器源码:https://github.com/moyuer1992...
预览地址:https://moyuer1992.github.io/...segmentfault
其中文字处理的核心代码:https://github.com/moyuer1992...
WebGL渲染核心代码:https://github.com/moyuer1992...api
获取文字像素信息是首要的步骤。数组
咱们利用一个离屏canvas绘制基本文字。设字号为size,项目中设size=200,并设置canvas边长和字号相同。这里size设置越大,得到的像素信息就更为精确,固然代价就是耗时更长,若是追求速度的话,能够将size减少。浏览器
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.font = size + 'px ' + (options.font || '隶书'); ctx.fillStyle = 'black'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, width / 2, height / 2);
获取像素信息:
var imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); var data = imageData.data;
好了,data变量就是咱们最终获得的像素数据。如今咱们来看一下data的数据结构:
能够看到,结果是一个长度为200x200x4的数组。200x200的canvas总共40000像素,每一个像素上的颜色由四个值来表示。因为使用黑色着色,前三位必然是0。第四位表示透明度,对于无颜色的像素,其值为0,对于有颜色的点,其值为大于零。因此,咱们若要判断该文字在第j行,i列上是否有值,只需判断data[(j ctx.canvas.width + i) 4 + 3]是否大于零便可。
因而,咱们能够写出判断某位置是否有颜色的函数:
var hasPixel = function (j, i) { //第j行,第i列 if (i < 0 || j < 0) { return false; } return !!data[(j * ctx.canvas.width + i) * 4 + 3]; };
接下来,咱们须要对文字笔画进行分割。这其实是一个寻找连通域的过程:把该文字当作一个图像,找到该图像上全部连通的部分,每个部分就是一个笔画。
寻找连通域的思路参考这篇文章:
算法大体分为几个步骤:
逐行扫描图像,记录每一行的连通段。
对每一个连通段进行标号。对第一行,从1开始依次为连通段进行标号。若非首行,则判断是否与上一行某个连通段连通,如果,则赋予该连通段的标号。
若某连通段同时与上一行两个连通段连通,则记录该关联对。
将全部关联对合并(即并查集的过程),获得每一个连通域的惟一标记。
下面是核心代码,关键变量定义以下:
g: width * height二维数组,表示每一个像素属于哪一个连通域。值为0表明该像素不在文字上,为透明值。
e: width * height二维数组,表示每一个像素是不是图像边缘。
markMap: 记录关联对。
cnt: 关联对合并前的总标记数量。
逐行扫描:
for (var j = 0; j < ctx.canvas.height; j += grid) { g.push([]); e.push([]); for (var i = 0; i < ctx.canvas.width; i += grid) { var value = 0; var isEdge = false; if (hasPixel(j, i)) { value = markPoint(j, i); } e[j][i] = isEdge; g[j][i] = value; } }
进行标记:
var markPoint = function (j, i) { var value = 0; if (i > 0 && hasPixel(j, i - 1)) { //与左边连通 value = g[j][i - 1]; } else { value = ++cnt; } if ( j > 0 && hasPixel(j - 1, i) && ( i === 0 || !hasPixel(j - 1, i - 1) ) ) { //与上连通 且 与左上不连通 (即首次和上一行链接) if (g[j - 1][i] !== value) { markMap.push([g[j - 1][i], value]); } } if ( !hasPixel(j, i - 1) ) { //行首 if ( hasPixel(j - 1, i - 1) && g[j - 1][i - 1] !== value) { //与左上连通 markMap.push([g[j - 1][i - 1], value]); } } if ( !hasPixel(j, i + 1) ) { //行尾 if ( hasPixel(j - 1, i + 1) && g[j - 1][i + 1] !== value) { //与右上连通 markMap.push([g[j - 1][i + 1], value]); } } return value; };
至此,将整个图像遍历一遍,已经完成了算法中1-3的步骤。接下来须要根据markMap中的关联信息,将标记归类,最终造成的图像,带有相同标记的像素在同一连通域中(即同一笔画)。
将标记关联对分类,是一个并查集问题,核心代码以下:
for (var i = 0; i < cnt; i++) { markArr[i] = i; } var findFather = function (n) { if (markArr[n] === n) { return n; } else { markArr[n] = findFather(markArr[n]); return markArr[n]; } } for (i = 0; i < markMap.length; i++) { var a = markMap[i][0]; var b = markMap[i][3]; var f1 = findFather(a); var f2 = findFather(b); if (f1 !== f2) { markArr[f2] = f1; } }
最终获得markArr数组,即记录了每个原标记号对应的最终类别标记。
打个比方:设上一步中标记完成的图像数组为g;假如markArr[3] = 1,mark[5] = 1, 则表示g中全部值为三、以及值为5的像素,最终都属于一个连通域,这个连通域标记为1。
根据markArr数组对g进行处理,咱们能够获得最终的连通域分割数据。
获得分割后的图像数据后,咱们能够gl.POINTS的形式利用WebGL进行渲染,且能够对不一样笔画设定不一样的颜色。但这并不知足咱们的须要。咱们但愿将文字渲染成一个三维立体的模型,这就意味着咱们要将二维的点阵转化成三维图形。
假设该文字有n个笔画,那么如今咱们拥有的数据能够当作n块连通的点阵。首先,咱们要将这n块文字点阵转换成n个二维平面图形。在WebGL中,全部的面都必须由三角形组成。这就意味着咱们要将一块点阵转换成一组毗邻的三角形。
可能你们想到的第一个思路就是将每三个相邻像素链接构成三角形,这确实是一种办法,但因为像素过多,这种方式耗时很长,并不推荐。
咱们解决这个问题的思路是:
找到每一个笔画(即每块连通域)的轮廓,并按顺时针顺序存储在数组中。
此时每一个连通域轮廓能够看作是一个多边形,此时能够用经典triangulation算法将其剖分红若干个三角形。
轮廓查找的算法一样能够参考这篇文章:
大体思路是首先找到第一个上方为空像素的点做为外轮廓起始点,记录入口方向为6(正上方),沿着顺时针方向寻找下一个链接像素,并记录入口方向,以此类推,直到终点与起始点重合。
接下来须要判断是否存在镂空,因此须要寻找内轮廓点,寻找第一个下方为空像素且不在任何轮廓上的点,做为该内轮廓起始点,记录入口为2(正下方),接下来步骤与寻找外轮廓相同。
注意图像可能不仅有一个内轮廓,因此这里须要循环判断。若不存在这样的像素,则无内轮廓。
经过前面的数据处理,咱们能够很容易判断某个像素是否处于轮廓之上:只要判断是否四周都存在非空像素便可。但关键问题在于,三角化算法须要“多边形”的顶点按顺序排列。这样一来,实际上核心逻辑在于如何按顺时针为轮廓像素排序。
对单个连通域进行轮廓顺序查找的方法以下:
变量定义:
v: 当前连通域标记号
g: width * height二维数组,表示每一个像素属于哪一个连通域。值为0表明该像素不在文字上,为透明值。若值为v则说明该像素处于当前连通域中。
e: width * height二维数组,表示每一个像素是不是图像边缘。
entryRecord: 入口方向标记数组
rs: 最终轮廓结果
holes: 如有内轮廓,则为内轮廓起始点(内轮廓点在数组最后面,如有多个内轮廓,则只需记录内轮廓起始位置便可,这样作是为了适应triangulation库earcut的参数设置,稍后会讲到)
代码:
function orderEdge (g, e, v, gap) { v++; var rs = []; var entryRecord = []; var start = findOuterContourEntry(g, v); var next = start; var end = false; rs.push(start); entryRecord.push(6); var holes = []; var mark; var holeMark = 2; e[start[1]][start[0]] = holeMark; var process = function (i, j) { if (i < 0 || i >= g[0].length || j < 0 || j >= g.length) { return false; } if (g[j][i] !== v || tmp) { return false; } e[j][i] = holeMark; tmp = [i, j] rs.push(tmp); mark = true; return true; } var map = [ (i,j) => {return {'i': i + 1, 'j': j}}, (i,j) => {return {'i': i + 1, 'j': j + 1}}, (i,j) => {return {'i': i, 'j': j +1}}, (i,j) => {return {'i': i - 1, 'j': j + 1}}, (i,j) => {return {'i': i - 1, 'j': j}}, (i,j) => {return {'i': i - 1, 'j': j - 1}}, (i,j) => {return {'i': i, 'j': j - 1}}, (i,j) => {return {'i': i + 1, 'j': j - 1}}, ]; var convertEntry = function (index) { var arr = [4, 5, 6, 7, 0, 1, 2, 3]; return arr[index]; } while (!end) { var i = next[0]; var j = next[1]; var tmp = null; var entryIndex = entryRecord[entryRecord.length - 1]; for (var c = 0; c < 8; c++) { var index = ((entryIndex + 1) + c) % 8; var hasNext = process(map[index](i, j).i, map[index](i, j).j); if (hasNext) { entryIndex = convertEntry(index); break; } } if (tmp) { next = tmp; if ((next[0] === start[0]) && (next[1] === start[1])) { var innerEntry = findInnerContourEntry(g, v, e); if (innerEntry) { next = start = innerEntry; e[start[1]][start[0]] = holeMark; rs.push(next); entryRecord.push(entryIndex); entryIndex = 2; holes.push(rs.length - 1); holeMark++; } else { end = true; } } } else { rs.splice(rs.length - 1, 1); entryIndex = convertEntry(entryRecord.splice(entryRecord.length - 1, 1)[0]); next = rs[rs.length - 1]; } entryRecord.push(entryIndex); } return [rs, holes]; }
function findOuterContourEntry (g, v) { var start = [-1, -1]; for (var j = 0; j < g.length; j++) { for (var i = 0; i < g[0].length; i++) { if (g[j][i] === v) { start = [i, j]; return start; } } } return start; }
function findInnerContourEntry (g, v, e) { var start = false; for (var j = 0; j < g.length; j++) { for (var i = 0; i < g[0].length; i++) { if (g[j][i] === v && (g[j + 1] && g[j + 1][i] === 0)) { var isInContours = false; if (typeof(e[j][i]) === 'number') { isInContours = true; } if (!isInContours) { start = [i, j]; return start; } } } } return start; }
为了特别检查内轮廓的查找,咱们找一个拥有环状连通域的文字测试一下:
看到一切ok,那么这一步就大功告成了。
对于triangulation的过程,咱们用开源库earcut进行处理。earcut项目地址:
利用earcut计算出三角形数组:
var triangles = earcut(flatten(points), holes);
对于每个三角形,进入着色器时须要设置三个顶点的坐标,同时计算该三角形平面的法向量。对于由a,b,c三个顶点构成的三角形,法向量计算以下:
var normal = cross(subtract(b, a), subtract(c, a));
咱们如今只获得了文字的一个面。既然想制做立体文字,咱们须要同时计算出文字的正面、背面、以及侧面。
正面和背面很容易获得:
for (var n = 0; n < triangles.length; n += 3) { var a = points[triangles[n]]; var b = points[triangles[n + 1]]; var c = points[triangles[n + 2]]; //=====字体正面数据===== triangle(vec3(a[0], a[1], z), vec3(b[0], b[1], z), vec3(c[0], c[1], z), index); //=====字体背面数据===== triangle(vec3(a[0], a[1], z2), vec3(b[0], b[1], z2), vec3(c[0], c[1], z2), index); }
重点在于侧面的构造,这里须要同时考虑内外轮廓。轮廓上每组相邻点的正、背面可构成一个矩形,将矩形剖分红两个三角形,便可获得侧面的构造。代码以下:
var holesMap = []; var last = 0; if (holes.length) { for (var holeIndex = 0; holeIndex < holes.length; holeIndex++) { holesMap.push([last, holes[holeIndex] - 1]); last = holes[holeIndex]; } } holesMap.push([last, points.length - 1]); for (var i = 0; i < holesMap.length; i++) { var startAt = holesMap[i][0]; var endAt = holesMap[i][1]; for (var j = startAt; j < endAt; j++) { triangle(vec3(points[j][0], points[j][1], z), vec3(points[j][0], points[j][1], z2), vec3(points[j+1][0], points[j+1][1], z), index); triangle(vec3(points[j][0], points[j][1], z2), vec3(points[j+1][0], points[j+1][1], z2), vec3(points[j+1][0], points[j+1][1], z), index); } triangle(vec3(points[startAt][0], points[startAt][1], z), vec3(points[startAt][0], points[startAt][1], z2), vec3(points[endAt][0], points[endAt][1], z), index); triangle(vec3(points[startAt][0], points[startAt][1], z2), vec3(points[endAt][0], points[endAt][1], z2), vec3(points[endAt][0], points[endAt][1], z), index); }
至此为止,咱们已经将全部须要的数据处理完毕,接下来,咱们须要把有用的参数传给顶点着色器。
传入到顶点着色器中的参数定义以下:
attribute vec3 vPosition; attribute vec4 vNormal; uniform vec4 ambientProduct, diffuseProduct, specularProduct; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform vec4 lightPosition; uniform float shininess; uniform mat3 normalMatrix;
从顶点着色器输出到片元着色器的变量定义以下:
varying vec4 fColor;
顶点着色器关键代码:
vec4 aPosition = vec4(vPosition, 1.0); …… gl_Position = projectionMatrix * modelViewMatrix * aPosition; fColor = ambient + diffuse +specular;
片元着色器关键代码:
gl_FragColor = fColor;
一个立体汉字的渲染已经完成了。你必定以为这种效果不够酷炫,或许还想为它加一些动画,不要着急,下一篇文章会抛砖引玉讲一个文字效果及动画的设计。