资讯专栏INFORMATION COLUMN

Canvas + WebGL中文艺术字渲染

baihe / 3240人阅读

摘要:将所有关联对合并即并查集的过程,得到每个连通域的唯一标记。此时每个连通域轮廓可以看做是一个多边形,此时可以用经典算法将其剖分成若干个三角形。若值为则说明该像素处于当前连通域中。二维数组,表示每个像素是否是图像边缘。

</>复制代码

  1. 笔者另一篇文章 https://segmentfault.com/a/11... 讲了基于Canvas的文本编辑器“简诗”的实现,其中文字由WebGL渲染艺术效果,这篇文章主要讲述由Canvas获取字体数据、笔画分割解析、以及由WebGL进行效果渲染的过程。

导言

用canvas原生api可以很容易地绘制文字,但是原生api提供的文字效果美化功能十分有限。如果想要绘制除描边、渐变这些常用效果以外的艺术字,又不用耗时耗力专门制作字体库的话,利用WebGL进行渲染是一种不错的选择。

这篇文章主要讲述如何利用canvas原生api获取文字像素数据,并对其进行笔画分割、边缘查找、法线计算等处理,最后将这些信息传入着色器,实现基本的光照立体文字。

利用canvas原生api获取文字像素信息的好处是,可以绘制任何浏览器支持的字体,而无需制作额外的字体文件;而缺陷是对一些高级需求(如笔画分割)的数据处理,时间复杂度较高。但对于个人项目而言,这是做出自定义艺术字效果比较快捷的方法。

最后实现的效果:

本文的重点在于文字数据的处理,所以只用了比较简单的渲染效果,但有了这些数据,很容易设计出更为酷炫的文字艺术效果。

“简诗”编辑器源码:https://github.com/moyuer1992...
预览地址:https://moyuer1992.github.io/...

其中文字处理的核心代码:https://github.com/moyuer1992...
WebGL渲染核心代码:https://github.com/moyuer1992...

canvas 获取字体像素

获取文字像素信息是首要的步骤。

我们利用一个离屏canvas绘制基本文字。设字号为size,项目中设size=200,并设置canvas边长和字号相同。这里size设置越大,获得的像素信息就更为精确,当然代价就是耗时更长,如果追求速度的话,可以将size减小。

</>复制代码

  1. ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  2. ctx.font = size + "px " + (options.font || "隶书");
  3. ctx.fillStyle = "black";
  4. ctx.textAlign = "center";
  5. ctx.textBaseline = "middle";
  6. ctx.fillText(text, width / 2, height / 2);

获取像素信息:

</>复制代码

  1. var imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
  2. var data = imageData.data;

好了,data变量就是我们最终得到的像素数据。现在我们来看一下data的数据结构:

可以看到,结果是一个长度为200x200x4的数组。200x200的canvas总共40000像素,每个像素上的颜色由四个值来表示。由于使用黑色着色,前三位必然是0。第四位表示透明度,对于无颜色的像素,其值为0,对于有颜色的点,其值为大于零。所以,我们若要判断该文字在第j行,i列上是否有值,只需判断data[(j ctx.canvas.width + i) 4 + 3]是否大于零即可。

于是,我们可以写出判断某位置是否有颜色的函数:

</>复制代码

  1. var hasPixel = function (j, i) {
  2. //第j行,第i列
  3. if (i < 0 || j < 0) {
  4. return false;
  5. }
  6. return !!data[(j * ctx.canvas.width + i) * 4 + 3];
  7. };
笔画分割

接下来,我们需要对文字笔画进行分割。这实际上是一个寻找连通域的过程:把该文字看成一个图像,找到该图像上所有连通的部分,每一个部分就是一个笔画。

寻找连通域的思路参考这篇文章:

</>复制代码

  1. http://www.cnblogs.com/ronny/...

算法大致分为几个步骤:

逐行扫描图像,记录每一行的连通段。

对每个连通段进行标号。对第一行,从1开始依次为连通段进行标号。若非首行,则判断是否与上一行某个连通段连通,若是,则赋予该连通段的标号。

若某连通段同时与上一行两个连通段连通,则记录该关联对。

将所有关联对合并(即并查集的过程),得到每个连通域的唯一标记。

下面是核心代码,关键变量定义如下:

g: width * height二维数组,表示每个像素属于哪个连通域。值为0代表该像素不在文字上,为透明值。

e: width * height二维数组,表示每个像素是否是图像边缘。

markMap: 记录关联对。

cnt: 关联对合并前的总标记数量。

逐行扫描:

</>复制代码

  1. for (var j = 0; j < ctx.canvas.height; j += grid) {
  2. g.push([]);
  3. e.push([]);
  4. for (var i = 0; i < ctx.canvas.width; i += grid) {
  5. var value = 0;
  6. var isEdge = false;
  7. if (hasPixel(j, i)) {
  8. value = markPoint(j, i);
  9. }
  10. e[j][i] = isEdge;
  11. g[j][i] = value;
  12. }
  13. }

进行标记:

</>复制代码

  1. var markPoint = function (j, i) {
  2. var value = 0;
  3. if (i > 0 && hasPixel(j, i - 1)) {
  4. //与左边连通
  5. value = g[j][i - 1];
  6. } else {
  7. value = ++cnt;
  8. }
  9. if ( j > 0 && hasPixel(j - 1, i) && ( i === 0 || !hasPixel(j - 1, i - 1) ) ) {
  10. //与上连通 且 与左上不连通 (即首次和上一行连接)
  11. if (g[j - 1][i] !== value) {
  12. markMap.push([g[j - 1][i], value]);
  13. }
  14. }
  15. if ( !hasPixel(j, i - 1) ) {
  16. //行首
  17. if ( hasPixel(j - 1, i - 1) && g[j - 1][i - 1] !== value) {
  18. //与左上连通
  19. markMap.push([g[j - 1][i - 1], value]);
  20. }
  21. }
  22. if ( !hasPixel(j, i + 1) ) {
  23. //行尾
  24. if ( hasPixel(j - 1, i + 1) && g[j - 1][i + 1] !== value) {
  25. //与右上连通
  26. markMap.push([g[j - 1][i + 1], value]);
  27. }
  28. }
  29. return value;
  30. };

至此,将整个图像遍历一遍,已经完成了算法中1-3的步骤。接下来需要根据markMap中的关联信息,将标记归类,最终形成的图像,带有相同标记的像素在同一连通域中(即同一笔画)。

将标记关联对分类,是一个并查集问题,核心代码如下:

</>复制代码

  1. for (var i = 0; i < cnt; i++) {
  2. markArr[i] = i;
  3. }
  4. var findFather = function (n) {
  5. if (markArr[n] === n) {
  6. return n;
  7. } else {
  8. markArr[n] = findFather(markArr[n]);
  9. return markArr[n];
  10. }
  11. }
  12. for (i = 0; i < markMap.length; i++) {
  13. var a = markMap[i][0];
  14. var b = markMap[i][3];
  15. var f1 = findFather(a);
  16. var f2 = findFather(b);
  17. if (f1 !== f2) {
  18. markArr[f2] = f1;
  19. }
  20. }

最终得到markArr数组,即记录了每一个原标记号对应的最终类别标记。
打个比方:设上一步中标记完成的图像数组为g;假如markArr[3] = 1,mark[5] = 1, 则表示g中所有值为3、以及值为5的像素,最终都属于一个连通域,这个连通域标记为1。
根据markArr数组对g进行处理,我们可以得到最终的连通域分割数据。

文字轮廓查找

得到分割后的图像数据后,我们可以gl.POINTS的形式利用WebGL进行渲染,且可以对不同笔画设定不同的颜色。但这并不满足我们的需要。我们希望将文字渲染成一个三维立体的模型,这就意味着我们要将二维的点阵转化成三维图形。

假设该文字有n个笔画,那么现在我们拥有的数据可以看成n块连通的点阵。首先,我们要将这n块文字点阵转换成n个二维平面图形。在WebGL中,所有的面都必须由三角形组成。这就意味着我们要将一块点阵转换成一组毗邻的三角形。

可能大家想到的第一个思路就是将每三个相邻像素连接构成三角形,这确实是一种办法,但由于像素过多,这种方式耗时很长,并不推荐。

我们解决这个问题的思路是:

找到每个笔画(即每块连通域)的轮廓,并按顺时针顺序存储在数组中。

此时每个连通域轮廓可以看做是一个多边形,此时可以用经典triangulation算法将其剖分成若干个三角形。

轮廓查找的算法同样可以参考这篇文章:

</>复制代码

  1. http://www.cnblogs.com/ronny/...

大致思路是首先找到第一个上方为空像素的点作为外轮廓起始点,记录入口方向为6(正上方),沿着顺时针方向寻找下一个连接像素,并记录入口方向,以此类推,直到终点与起始点重合。

接下来需要判断是否存在镂空,所以需要寻找内轮廓点,寻找第一个下方为空像素且不在任何轮廓上的点,作为该内轮廓起始点,记录入口为2(正下方),接下来步骤与寻找外轮廓相同。
注意图像可能不只有一个内轮廓,所以这里需要循环判断。若不存在这样的像素,则无内轮廓。

通过前面的数据处理,我们可以很容易判断某个像素是否处于轮廓之上:只要判断是否四周都存在非空像素即可。但关键问题在于,三角化算法需要“多边形”的顶点按顺序排列。这样一来,实际上核心逻辑在于如何按顺时针为轮廓像素排序。

对单个连通域进行轮廓顺序查找的方法如下:

变量定义:

v: 当前连通域标记号

g: width * height二维数组,表示每个像素属于哪个连通域。值为0代表该像素不在文字上,为透明值。若值为v则说明该像素处于当前连通域中。

e: width * height二维数组,表示每个像素是否是图像边缘。

entryRecord: 入口方向标记数组

rs: 最终轮廓结果

holes: 若有内轮廓,则为内轮廓起始点(内轮廓点在数组最后面,若有多个内轮廓,则只需记录内轮廓起始位置即可,这样做是为了适应triangulation库earcut的参数设置,稍后会讲到)

代码:

</>复制代码

  1. function orderEdge (g, e, v, gap) {
  2. v++;
  3. var rs = [];
  4. var entryRecord = [];
  5. var start = findOuterContourEntry(g, v);
  6. var next = start;
  7. var end = false;
  8. rs.push(start);
  9. entryRecord.push(6);
  10. var holes = [];
  11. var mark;
  12. var holeMark = 2;
  13. e[start[1]][start[0]] = holeMark;
  14. var process = function (i, j) {
  15. if (i < 0 || i >= g[0].length || j < 0 || j >= g.length) {
  16. return false;
  17. }
  18. if (g[j][i] !== v || tmp) {
  19. return false;
  20. }
  21. e[j][i] = holeMark;
  22. tmp = [i, j]
  23. rs.push(tmp);
  24. mark = true;
  25. return true;
  26. }
  27. var map = [
  28. (i,j) => {return {"i": i + 1, "j": j}},
  29. (i,j) => {return {"i": i + 1, "j": j + 1}},
  30. (i,j) => {return {"i": i, "j": j +1}},
  31. (i,j) => {return {"i": i - 1, "j": j + 1}},
  32. (i,j) => {return {"i": i - 1, "j": j}},
  33. (i,j) => {return {"i": i - 1, "j": j - 1}},
  34. (i,j) => {return {"i": i, "j": j - 1}},
  35. (i,j) => {return {"i": i + 1, "j": j - 1}},
  36. ];
  37. var convertEntry = function (index) {
  38. var arr = [4, 5, 6, 7, 0, 1, 2, 3];
  39. return arr[index];
  40. }
  41. while (!end) {
  42. var i = next[0];
  43. var j = next[1];
  44. var tmp = null;
  45. var entryIndex = entryRecord[entryRecord.length - 1];
  46. for (var c = 0; c < 8; c++) {
  47. var index = ((entryIndex + 1) + c) % 8;
  48. var hasNext = process(map[index](i, j).i, map[index](i, j).j);
  49. if (hasNext) {
  50. entryIndex = convertEntry(index);
  51. break;
  52. }
  53. }
  54. if (tmp) {
  55. next = tmp;
  56. if ((next[0] === start[0]) && (next[1] === start[1])) {
  57. var innerEntry = findInnerContourEntry(g, v, e);
  58. if (innerEntry) {
  59. next = start = innerEntry;
  60. e[start[1]][start[0]] = holeMark;
  61. rs.push(next);
  62. entryRecord.push(entryIndex);
  63. entryIndex = 2;
  64. holes.push(rs.length - 1);
  65. holeMark++;
  66. } else {
  67. end = true;
  68. }
  69. }
  70. } else {
  71. rs.splice(rs.length - 1, 1);
  72. entryIndex = convertEntry(entryRecord.splice(entryRecord.length - 1, 1)[0]);
  73. next = rs[rs.length - 1];
  74. }
  75. entryRecord.push(entryIndex);
  76. }
  77. return [rs, holes];
  78. }

</>复制代码

  1. function findOuterContourEntry (g, v) {
  2. var start = [-1, -1];
  3. for (var j = 0; j < g.length; j++) {
  4. for (var i = 0; i < g[0].length; i++) {
  5. if (g[j][i] === v) {
  6. start = [i, j];
  7. return start;
  8. }
  9. }
  10. }
  11. return start;
  12. }

</>复制代码

  1. function findInnerContourEntry (g, v, e) {
  2. var start = false;
  3. for (var j = 0; j < g.length; j++) {
  4. for (var i = 0; i < g[0].length; i++) {
  5. if (g[j][i] === v && (g[j + 1] && g[j + 1][i] === 0)) {
  6. var isInContours = false;
  7. if (typeof(e[j][i]) === "number") {
  8. isInContours = true;
  9. }
  10. if (!isInContours) {
  11. start = [i, j];
  12. return start;
  13. }
  14. }
  15. }
  16. }
  17. return start;
  18. }

为了特别检查内轮廓的查找,我们找一个拥有环状连通域的文字测试一下:

看到一切ok,那么这一步就大功告成了。

triangulation构造平面

对于triangulation的过程,我们用开源库earcut进行处理。earcut项目地址:

</>复制代码

  1. https://github.com/mapbox/earcut

利用earcut计算出三角形数组:

</>复制代码

  1. var triangles = earcut(flatten(points), holes);

对于每一个三角形,进入着色器时需要设置三个顶点的坐标,同时计算该三角形平面的法向量。对于由a,b,c三个顶点构成的三角形,法向量计算如下:

</>复制代码

  1. var normal = cross(subtract(b, a), subtract(c, a));
文字立体模型的建立

我们现在只得到了文字的一个面。既然想制作立体文字,我们需要同时计算出文字的正面、背面、以及侧面。

正面和背面很容易得到:

</>复制代码

  1. for (var n = 0; n < triangles.length; n += 3) {
  2. var a = points[triangles[n]];
  3. var b = points[triangles[n + 1]];
  4. var c = points[triangles[n + 2]];
  5. //=====字体正面数据=====
  6. triangle(vec3(a[0], a[1], z), vec3(b[0], b[1], z), vec3(c[0], c[1], z), index);
  7. //=====字体背面数据=====
  8. triangle(vec3(a[0], a[1], z2), vec3(b[0], b[1], z2), vec3(c[0], c[1], z2), index);
  9. }

重点在于侧面的构造,这里需要同时考虑内外轮廓。轮廓上每组相邻点的正、背面可构成一个矩形,将矩形剖分成两个三角形,即可得到侧面的构造。代码如下:

</>复制代码

  1. var holesMap = [];
  2. var last = 0;
  3. if (holes.length) {
  4. for (var holeIndex = 0; holeIndex < holes.length; holeIndex++) {
  5. holesMap.push([last, holes[holeIndex] - 1]);
  6. last = holes[holeIndex];
  7. }
  8. }
  9. holesMap.push([last, points.length - 1]);
  10. for (var i = 0; i < holesMap.length; i++) {
  11. var startAt = holesMap[i][0];
  12. var endAt = holesMap[i][1];
  13. for (var j = startAt; j < endAt; j++) {
  14. 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);
  15. 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);
  16. }
  17. 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);
  18. 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);
  19. }
WebGL渲染

至此为止,我们已经将所有需要的数据处理完毕,接下来,我们需要把有用的参数传给顶点着色器。

传入到顶点着色器中的参数定义如下:

</>复制代码

  1. attribute vec3 vPosition;
  2. attribute vec4 vNormal;
  3. uniform vec4 ambientProduct, diffuseProduct, specularProduct;
  4. uniform mat4 modelViewMatrix;
  5. uniform mat4 projectionMatrix;
  6. uniform vec4 lightPosition;
  7. uniform float shininess;
  8. uniform mat3 normalMatrix;

从顶点着色器输出到片元着色器的变量定义如下:

</>复制代码

  1. varying vec4 fColor;

顶点着色器关键代码:

</>复制代码

  1. vec4 aPosition = vec4(vPosition, 1.0);
  2. ……
  3. gl_Position = projectionMatrix * modelViewMatrix * aPosition;
  4. fColor = ambient + diffuse +specular;

片元着色器关键代码:

</>复制代码

  1. gl_FragColor = fColor;
后续

一个立体汉字的渲染已经完成了。你一定觉得这种效果不够酷炫,或许还想为它加一些动画,不要着急,下一篇文章会抛砖引玉讲一个文字效果及动画的设计。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/82276.html

相关文章

  • Canvas实现文本编辑器(支持艺术渲染与动画)

    摘要:项目中文字由进行渲染。待触发时,取消中文输入标记,将文字渲染到上。而其中一些有趣的细节实现如文本渲染,对中文笔画分割实现有趣的动画等并没有描写。 导言 目前富文本编辑器的实现主要有两种技术方案:一个是利用contenteditable属性直接对html元素进行编辑,如draft.js;另一种是代理textarea + 自定义div + 模拟光标实现。对于类似word的经典富文本编辑器,...

    OldPanda 评论0 收藏0
  • SegmentFault 技术周刊 Vol.35 - WebGL:打开网页看大片

    摘要:在文末,我会附上一个可加载的模型方便学习中文艺术字渲染用原生可以很容易地绘制文字,但是原生提供的文字效果美化功能十分有限。 showImg(https://segmentfault.com/img/bVWYnb?w=900&h=385); WebGL 可以说是 HTML5 技术生态链中最为令人振奋的标准之一,它把 Web 带入了 3D 的时代。 初识 WebGL 先通过几个使用 Web...

    objc94 评论0 收藏0
  • WebGL入门demo

    摘要:而是一款框架,由于其易用性被广泛应用。如果要学习,抛弃那些复杂的原生接口从这款框架入手是一个不错的选择。需要相机,演员这里是地球,场景,导演。最后拍好了戏交给渲染器来制片,发布。状态也在不停的更新。 WebGL入门demo three.js入门 开场白 哇哦,绘制气球耶,在网页上?对啊!厉害了!3D效果图也能在网页上绘制出来啊,这么好玩的事情,赶紧来看看! 这里是属于WebGL的应用,...

    lijinke666 评论0 收藏0
  • 前端动画调研-V1

    摘要:支持动画状态的,在动画开始,执行中,结束时提供回调函数支持动画可以自定义贝塞尔曲线任何包含数值的属性都可以设置动画仓库文档演示功能介绍一定程度上,也是一个动画库,适用所有的属性,并且实现的能更方便的实现帧动画,替代复杂的定义方式。 动画调研-V1 前言:动画从用途上可以分为两种,一种是展示型的动画,类似于一张GIF图,或者一段视频,另一种就是交互性的动画。这两种都有具体的应用场景,比如...

    ddongjian0000 评论0 收藏0

发表评论

0条评论

baihe

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<