Canvas + WebGL中文藝術字渲染

筆者另外一篇文章 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 獲取字體像素

獲取文字像素信息是首要的步驟。數組

咱們利用一個離屏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];
};

筆畫分割

接下來,咱們須要對文字筆畫進行分割。這其實是一個尋找連通域的過程:把該文字當作一個圖像,找到該圖像上全部連通的部分,每個部分就是一個筆畫。

尋找連通域的思路參考這篇文章:

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

算法大體分爲幾個步驟:

  1. 逐行掃描圖像,記錄每一行的連通段。

  2. 對每一個連通段進行標號。對第一行,從1開始依次爲連通段進行標號。若非首行,則判斷是否與上一行某個連通段連通,如果,則賦予該連通段的標號。

  3. 若某連通段同時與上一行兩個連通段連通,則記錄該關聯對。

  4. 將全部關聯對合並(即並查集的過程),獲得每一個連通域的惟一標記。

下面是核心代碼,關鍵變量定義以下:

  • 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中,全部的面都必須由三角形組成。這就意味着咱們要將一塊點陣轉換成一組毗鄰的三角形。

可能你們想到的第一個思路就是將每三個相鄰像素鏈接構成三角形,這確實是一種辦法,但因爲像素過多,這種方式耗時很長,並不推薦。

咱們解決這個問題的思路是:

  1. 找到每一個筆畫(即每塊連通域)的輪廓,並按順時針順序存儲在數組中。

  2. 此時每一個連通域輪廓能夠看作是一個多邊形,此時能夠用經典triangulation算法將其剖分紅若干個三角形。

輪廓查找的算法一樣能夠參考這篇文章:

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

大體思路是首先找到第一個上方爲空像素的點做爲外輪廓起始點,記錄入口方向爲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項目地址:

https://github.com/mapbox/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;

後續

一個立體漢字的渲染已經完成了。你必定以爲這種效果不夠酷炫,或許還想爲它加一些動畫,不要着急,下一篇文章會拋磚引玉講一個文字效果及動畫的設計。

相關文章
相關標籤/搜索