目录

WebGL2Fundamentals.org

Fix, Fork, Contribute

WebGL2 文字 - 使用字形纹理

此文上接 WebGL 系列文章,上一篇是在 WebGL 中使用纹理渲染文字, 如果没读可以先看那些。

上篇文章中讲到了如何使用纹理在 WebGL 场景中绘制文字, 那个技术非常常用,并且常用在多人游戏角色头顶名称中,由于那个名字很少改变所以很好用。

假如你想在 UI 中绘制很多文字并且经常改变,接着上文 的例子我们可以为每个字母制作一个纹理。让我们修改代码实现。

+var names = [
+  "anna",   // 0
+  "colin",  // 1
+  "james",  // 2
+  "danny",  // 3
+  "kalin",  // 4
+  "hiro",   // 5
+  "eddie",  // 6
+  "shu",    // 7
+  "brian",  // 8
+  "tami",   // 9
+  "rick",   // 10
+  "gene",   // 11
+  "natalie",// 12,
+  "evan",   // 13,
+  "sakura", // 14,
+  "kai",    // 15,
+];

// 创建文字纹理,一个字母一个
var textTextures = [
+  "a",    // 0
+  "b",    // 1
+  "c",    // 2
+  "d",    // 3
+  "e",    // 4
+  "f",    // 5
+  "g",    // 6
+  "h",    // 7
+  "i",    // 8
+  "j",    // 9
+  "k",    // 10
+  "l",    // 11
+  "m",    // 12,
+  "n",    // 13,
+  "o",    // 14,
+  "p",    // 14,
+  "q",    // 14,
+  "r",    // 14,
+  "s",    // 14,
+  "t",    // 14,
+  "u",    // 14,
+  "v",    // 14,
+  "w",    // 14,
+  "x",    // 14,
+  "y",    // 14,
+  "z",    // 14,
].map(function(name) {
*  var textCanvas = makeTextCanvas(name, 10, 26);

然后为名字中每个字母渲染一个矩形而不是原来的只用一个矩形。

//  绘制文字设置
+// 因为每个字母使用的是相通的属性和程序
+// 我们只需要设置一次
+gl.useProgram(textProgramInfo.program);
+setBuffersAndAttributes(gl, textProgramInfo.attribSetters, textBufferInfo);

textPositions.forEach(function(pos, ndx) {
+  var name = names[ndx];
+
+  // 每个字母
+  for (var ii = 0; ii < name.length; ++ii) {
+    var letter = name.charCodeAt(ii);
+    var letterNdx = letter - "a".charCodeAt(0);
+
+    // 选择一个字母纹理
+    var tex = textTextures[letterNdx];

    // 使用 'F' 的位置

    // 由于 pos 在视图空间,表示它是一个从眼睛位置出发的一个向量
    // 所以沿着向量朝眼睛方向移动一定距离
    var fromEye = m4.normalize(pos);
    var amountToMoveTowardEye = 150;  // 因为 F 是 150 个单位长
    var viewX = pos[0] - fromEye[0] * amountToMoveTowardEye;
    var viewY = pos[1] - fromEye[1] * amountToMoveTowardEye;
    var viewZ = pos[2] - fromEye[2] * amountToMoveTowardEye;
    var desiredTextScale = -1 / gl.canvas.height;  // 1x1 像素大小
    var scale = viewZ * desiredTextScale;

    var textMatrix = m4.translate(projectionMatrix, viewX, viewY, viewZ);
    textMatrix = m4.scale(textMatrix, tex.width * scale, tex.height * scale, 1);
    +textMatrix = m4.translate(textMatrix, ii, 0, 0);

    // 设置全局变量
    textUniforms.u_texture = tex.texture;
    copyMatrix(textMatrix, textUniforms.u_matrix);
    setUniforms(textProgramInfo.uniformSetters, textUniforms);

    // 绘制文字
    gl.drawElements(gl.TRIANGLES, textBufferInfo.numElements, gl.UNSIGNED_SHORT, 0);
  }
});

查看结果

不幸的是它比较慢,例子中并没有体现出来这一点,但是我们绘制了 73 个矩形, 计算了 73 个矩阵并使用了 219 次矩阵乘法运算,一个典型的 UI 可能很容易超过 1000 个字母, 这种方式就会在每一帧需要进行大量计算。

为了解决这个问题通常是使用一个包含所有字母的纹理图集, 我们在给立方体 6 各面贴图 的例子中讲到过纹理图集。

我在网上找到了一个简单的开源字体纹理图集

让我们制作一些可以用来帮助生成位置和纹理坐标的数据

var fontInfo = {
  letterHeight: 8,
  spaceWidth: 8,
  spacing: -1,
  textureWidth: 64,
  textureHeight: 40,
  glyphInfos: {
    'a': { x:  0, y:  0, width: 8, },
    'b': { x:  8, y:  0, width: 8, },
    'c': { x: 16, y:  0, width: 8, },
    'd': { x: 24, y:  0, width: 8, },
    'e': { x: 32, y:  0, width: 8, },
    'f': { x: 40, y:  0, width: 8, },
    'g': { x: 48, y:  0, width: 8, },
    'h': { x: 56, y:  0, width: 8, },
    'i': { x:  0, y:  8, width: 8, },
    'j': { x:  8, y:  8, width: 8, },
    'k': { x: 16, y:  8, width: 8, },
    'l': { x: 24, y:  8, width: 8, },
    'm': { x: 32, y:  8, width: 8, },
    'n': { x: 40, y:  8, width: 8, },
    'o': { x: 48, y:  8, width: 8, },
    'p': { x: 56, y:  8, width: 8, },
    'q': { x:  0, y: 16, width: 8, },
    'r': { x:  8, y: 16, width: 8, },
    's': { x: 16, y: 16, width: 8, },
    't': { x: 24, y: 16, width: 8, },
    'u': { x: 32, y: 16, width: 8, },
    'v': { x: 40, y: 16, width: 8, },
    'w': { x: 48, y: 16, width: 8, },
    'x': { x: 56, y: 16, width: 8, },
    'y': { x:  0, y: 24, width: 8, },
    'z': { x:  8, y: 24, width: 8, },
    '0': { x: 16, y: 24, width: 8, },
    '1': { x: 24, y: 24, width: 8, },
    '2': { x: 32, y: 24, width: 8, },
    '3': { x: 40, y: 24, width: 8, },
    '4': { x: 48, y: 24, width: 8, },
    '5': { x: 56, y: 24, width: 8, },
    '6': { x:  0, y: 32, width: 8, },
    '7': { x:  8, y: 32, width: 8, },
    '8': { x: 16, y: 32, width: 8, },
    '9': { x: 24, y: 32, width: 8, },
    '-': { x: 32, y: 32, width: 8, },
    '*': { x: 40, y: 32, width: 8, },
    '!': { x: 48, y: 32, width: 8, },
    '?': { x: 56, y: 32, width: 8, },
  },
};

然后我们像之前加载图像一样加载这个纹理

// 创建一个纹理
var glyphTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, glyphTex);
// 使用 1×1 蓝色像素像素填充纹理
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
              new Uint8Array([0, 0, 255, 255]));
// 异步加载图像
var image = new Image();
image.src = "resources/8x8-font.png";
image.addEventListener('load', function() {
  // 图像加载完成,将它拷贝到纹理
  gl.bindTexture(gl.TEXTURE_2D, glyphTex);
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,gl.UNSIGNED_BYTE, image);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
});

现在我们有了一个包含字形的纹理,可以在运行时给每个字形创建矩形,这些顶点将使用纹理坐标选择确切的字形。

给定字符串创建顶点。

function makeVerticesForString(fontInfo, s) {
  var len = s.length;
  var numVertices = len * 6;
  var positions = new Float32Array(numVertices * 2);
  var texcoords = new Float32Array(numVertices * 2);
  var offset = 0;
  var x = 0;
  var maxX = fontInfo.textureWidth;
  var maxY = fontInfo.textureHeight;
  for (var ii = 0; ii < len; ++ii) {
    var letter = s[ii];
    var glyphInfo = fontInfo.glyphInfos[letter];
    if (glyphInfo) {
      var x2 = x + glyphInfo.width;
      var u1 = glyphInfo.x / maxX;
      var v1 = (glyphInfo.y + fontInfo.letterHeight - 1) / maxY;
      var u2 = (glyphInfo.x + glyphInfo.width - 1) / maxX;
      var v2 = glyphInfo.y / maxY;

      // 每个字母 6 个顶点
      positions[offset + 0] = x;
      positions[offset + 1] = 0;
      texcoords[offset + 0] = u1;
      texcoords[offset + 1] = v1;

      positions[offset + 2] = x2;
      positions[offset + 3] = 0;
      texcoords[offset + 2] = u2;
      texcoords[offset + 3] = v1;

      positions[offset + 4] = x;
      positions[offset + 5] = fontInfo.letterHeight;
      texcoords[offset + 4] = u1;
      texcoords[offset + 5] = v2;

      positions[offset + 6] = x;
      positions[offset + 7] = fontInfo.letterHeight;
      texcoords[offset + 6] = u1;
      texcoords[offset + 7] = v2;

      positions[offset + 8] = x2;
      positions[offset + 9] = 0;
      texcoords[offset + 8] = u2;
      texcoords[offset + 9] = v1;

      positions[offset + 10] = x2;
      positions[offset + 11] = fontInfo.letterHeight;
      texcoords[offset + 10] = u2;
      texcoords[offset + 11] = v2;

      x += glyphInfo.width + fontInfo.spacing;
      offset += 12;
    } else {
      // 没有的字母就留一个间距
      x += fontInfo.spaceWidth;
    }
  }

  // 返回用到的 TypedArrays 的 ArrayBufferViews
  return {
    arrays: {
      position: new Float32Array(positions.buffer, 0, offset),
      texcoord: new Float32Array(texcoords.buffer, 0, offset),
    },
    numVertices: offset / 2,
  };
}

我们还要手动创建一个 bufferInfo(如果你不知道 bufferInfo 是什么可以看看这里)。

// Manually create a bufferInfo
var textBufferInfo = {
  attribs: {
    a_position: { buffer: gl.createBuffer(), numComponents: 2, },
    a_texcoord: { buffer: gl.createBuffer(), numComponents: 2, },
  },
  numElements: 0,
};

然后需要在渲染文字时更新缓冲,我们也让文字动态变化

textPositions.forEach(function(pos, ndx) {

  var name = names[ndx];
+  var s = name + ":" + pos[0].toFixed(0) + "," + pos[1].toFixed(0) + "," + pos[2].toFixed(0);
+  var vertices = makeVerticesForString(fontInfo, s);
+
+  // 更新缓冲
+  textBufferInfo.attribs.a_position.numComponents = 2;
+  gl.bindBuffer(gl.ARRAY_BUFFER, textBufferInfo.attribs.a_position.buffer);
+  gl.bufferData(gl.ARRAY_BUFFER, vertices.arrays.position, gl.DYNAMIC_DRAW);
+  gl.bindBuffer(gl.ARRAY_BUFFER, textBufferInfo.attribs.a_texcoord.buffer);
+  gl.bufferData(gl.ARRAY_BUFFER, vertices.arrays.texcoord, gl.DYNAMIC_DRAW);

  // 使用 'F' 的位置

  // 由于 pos 在视图空间,表示它是一个从眼睛位置出发的一个向量
  // 所以沿着向量朝眼睛方向移动一定距离
  var fromEye = m4.normalize(pos);
  var amountToMoveTowardEye = 150;  // 因为 F 是 150 个单位长
  var viewX = pos[0] - fromEye[0] * amountToMoveTowardEye;
  var viewY = pos[1] - fromEye[1] * amountToMoveTowardEye;
  var viewZ = pos[2] - fromEye[2] * amountToMoveTowardEye;
  var desiredTextScale = -1 / gl.canvas.height * 2;  // 1x1 像素大小
  var scale = viewZ * desiredTextScale;

  var textMatrix = m4.translate(projectionMatrix, viewX, viewY, viewZ);
  textMatrix = m4.scale(textMatrix, scale, scale, 1);

  // 设置绘制文字
  gl.useProgram(textProgramInfo.program);

  gl.bindVertexArray(textVAO);

  m4.copy(textMatrix, textUniforms.u_matrix);
  webglUtils.setUniforms(textProgramInfo, textUniforms);

  // 绘制文字
  gl.drawArrays(gl.TRIANGLES, 0, vertices.numVertices);
});

这是结果

这就是使用字形纹理图集的基本原理,还有几个明显值得添加或改进的地方。

  • 重用相同的序列

    当前 makeVerticesForString 在每次调用的时候都创建新的 Float32Arrays。 这最终可能会导致垃圾回收停顿。重用相同的序列可能会好一些,如果序列不够大可以放大并保持大小不变。

  • 添加回车支持

    检测是否是 \n 然后在创建顶点时换行,这样容易制作文字段落。

  • 添加各种格式化支持

    如果你想居中或对齐文字就可以添加这个功能。

  • 添加顶点颜色支持

    这样就可以对每个字母设置不同的颜色,当然你还需要决定如何改变文字颜色。

  • 考虑在运行时用二维画布创建字形纹理图集

另一个没有讲到的重要的问题是纹理限制了大小但是字体不限,如果你想支持所有 Unicode 编码就需要处理汉语和日语, 阿拉伯语等其他语言。截止 2015 年已经有了超过 110,000 种 Unicode 字形!你可以用纹理适应所有字形,只是没有足够的空间。

操作系统和浏览器解决这个问题的方式是,在使用 GPU 加速时使用字形纹理缓冲。 向上方一样它们可能将字形放在纹理图集中,但是每种字形可能有一个固定大小的空间, 只在纹理中保存最近常用的字形。如果需要绘制的字形不在纹理中,就替换掉最不常用的那个。 当然如果要替换的字形需要绘制的话,就要在绘制后再替换。

另一个可以做的事情,尽管我不推荐,是结合这个技术和上篇中的技术, 可以将字形直接渲染到一个纹理中。

还有一个在 WebGL 中绘制文字的方式就是使用三维文字,例如所有例子中的 'F' 就是一个三维字母。 你需要为每个字母创建一个,三位字母通常用于标题和电影 logo,其他地方很少用。

希望这些涵盖了 WebGL 的文字部分。

有意见或建议? 在GitHub上提issue.
comments powered by Disqus