筆者另一篇文章 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減小。
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中所有值為3、以及值為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構造平面
對於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);
}
WebGL渲染
至此為止,我們已經將所有需要的資料處理完畢,接下來,我們需要把有用的引數傳給頂點著色器。
傳入到頂點著色器中的引數定義如下:
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;
後續
一個立體漢字的渲染已經完成了。你一定覺得這種效果不夠酷炫,或許還想為它加一些動畫,不要著急,下一篇文章會拋磚引玉講一個文字效果及動畫的設計。