目录

WebGL2Fundamentals.org

Fix, Fork, Contribute

WebGL2 文字 - 使用纹理

此文上接 WebGL 系列文章,上一篇是用 Canvas 2D 在 WebGL 画布上叠加一个文字层,如果没读建议从那里开始。

在上文中我们讲到如何在 WebGL 场景上方绘制一个二维画布文字层, 那种方法可行并且容易实现,但是有一些限制,比如不能被三维物体遮挡。为了实现这个就需要在 WebGL 中绘制文字。

最简单的方式是制作一个文字纹理,你可以使用 PhotoShop 或其他绘图软件制作一个含有文字的图片。

然后创建平整的几何体显示它,这其实是我做过的所有游戏使用的方法。例如 Locoroco 只有大约 270 个句子,它被本地化为 17 种语言。我们有一个 Excel 表格包含了所有语言的句子, 然后使用一个脚本将它们加载到 PhotoShop 种生成纹理,每种语言每个句子做成一个纹理。

当然你也可以在运行时创建纹理,由于 WebGL 运行在浏览器中,我们可以借助 Canvas 2D API 帮助生成纹理。

上文的例子开始,添加一个方法向二维画布种填充文字

var textCtx = document.createElement("canvas").getContext("2d");

// 将文字放在画布中间
function makeTextCanvas(text, width, height) {
  textCtx.canvas.width  = width;
  textCtx.canvas.height = height;
  textCtx.font = "20px monospace";
  textCtx.textAlign = "center";
  textCtx.textBaseline = "middle";
  textCtx.fillStyle = "black";
  textCtx.clearRect(0, 0, textCtx.canvas.width, textCtx.canvas.height);
  textCtx.fillText(text, width / 2, height / 2);
  return textCtx.canvas;
}

现在需要使用 WebGL 绘制两个不同的物体,'F' 和文字。我将使用 之前讲到的帮助方法, 如果你不清楚 programInfo, bufferInfo 是什么,就看看那篇文章。

所以,先创建 'F' 和单位矩形。

// 创建 'F' 的数据
var fBufferInfo = primitives.create3DFBufferInfo(gl);
var fVAO = webglUtils.createVAOFromBufferInfo(
    gl, fProgramInfo, fBufferInfo);

// 创建一个单位矩形供文字使用
var textBufferInfo = primitives.createXYQuadBufferInfo(gl, 1);
var textVAO = webglUtils.createVAOFromBufferInfo(
    gl, textProgramInfo, textBufferInfo);

XY 矩形是一个单位大小的矩形(正方形),这个矩形以原点为中心。 作为 1 个单位,它的范围是 -0.5、-0.5 和 0.5、0.5

接着创建两个着色器

// 设置着色程序
var fProgramInfo = webglUtils.createProgramInfo(
    gl, [fVertexShaderSource, fFragmentShaderSource]);
var textProgramInfo = webglUtils.createProgramInfo(
    gl, [textVertexShaderSource, textFragmentShaderSource]);

创建文字纹理,我们生成贴图因为文本会变小 And create our text texture. We generate mips since the text will get small

// 创建文字纹理
var textCanvas = makeTextCanvas("Hello!", 100, 26);
var textWidth  = textCanvas.width;
var textHeight = textCanvas.height;
var textTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, textTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);
gl.generateMipmap(gl.TEXTURE_2D);
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);

设置 'F' 和文字的全局变量

var fUniforms = {
  u_matrix: m4.identity(),
};

var textUniforms = {
  u_matrix: m4.identity(),
  u_texture: textTex,
};

现在,当我们计算 F 的矩阵时,我们从 viewMatrix 开始,而不是像其他例子一样使用 viewProjectionMatrix。 我们将其乘以构成 F 方向的部分

var fViewMatrix = m4.translate(viewMatrix,
    translation[0] + xx * spread, translation[1] + yy * spread, translation[2]);
fViewMatrix = m4.xRotate(fViewMatrix, rotation[0]);
fViewMatrix = m4.yRotate(fViewMatrix, rotation[1] + yy * xx * 0.2);
fViewMatrix = m4.zRotate(fViewMatrix, rotation[2] + now + (yy * 3 + xx) * 0.1);
fViewMatrix = m4.scale(fViewMatrix, scale[0], scale[1], scale[2]);
fViewMatrix = m4.translate(fViewMatrix, -50, -75, 0);

最后,我们在设置统一值时在投影矩阵中进行乘法运算。

fUniforms.u_matrix = m4.multiply(projectionMatrix, fViewMatrix);

重要的是要注意,projectionMatrix 在左边。 这让我们可以在投影矩阵中进行乘法运算,就好像它是第一个矩阵一样。 通常我们在右边相乘。

像这样绘制 F

// 设置绘制 'F'
gl.useProgram(fProgramInfo.program);

// 设置属性和缓冲
gl.bindVertexArray(fVAO);

fUniforms.u_matrix = m4.multiply(projectionMatrix, fViewMatrix);

webglUtils.setUniforms(fProgramInfo, fUniforms);

webglUtils.drawBufferInfo(gl, fBufferInfo);

对于文本,我们从 projectionMatrix 开始,然后仅从我们之前保存的 fViewMatrix 中获取位置。 这将使我们在视图前面有一个空间。 我们还需要缩放我们的单位四边形以匹配纹理的尺寸。

// 只使用 'F' 视图矩阵的位置
var textMatrix = m4.translate(projectionMatrix,
    fViewMatrix[12], fViewMatrix[13], fViewMatrix[14]);
// 缩放 F 到所需大小
textMatrix = m4.scale(textMatrix, textWidth, textHeight, 1);

然后渲染文字

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

gl.bindVertexArray(textVAO);

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

// 绘制文字
webglUtils.drawBufferInfo(gl, textBufferInfo);

这是结果

你可能注意到文字部分覆盖了 F,那是因为我们绘制了一个矩形,画布的默认颜色是黑色透明(0,0,0,0), 然后我们将它绘制到矩形上了,我们可以混合像素。

gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

这样将源像素(片断着色器产生的颜色)和目标像素(画布上的颜色)的颜色根据混合方法进行混合, 我们设置混合方法为 SRC_ALPHA 对源,ONE_MINUS_SRC_ALPHA 对目标。

result = dest * (1 - src_alpha) + src * src_alpha

所以加入目标像素是绿色 0,1,0,1,源是红色 1,0,0,1 就得到

src = [1, 0, 0, 1]
dst = [0, 1, 0, 1]
src_alpha = src[3]  // this is 1
result = dst * (1 - src_alpha) + src * src_alpha

// 相当于
result = dst * 0 + src * 1

// 最后结果
result = src

对于黑色透明的部分的纹理 0,0,0,0

src = [0, 0, 0, 0]
dst = [0, 1, 0, 1]
src_alpha = src[3]  // this is 0
result = dst * (1 - src_alpha) + src * src_alpha

// 相当于
result = dst * 1 + src * 0

// 最后结果
result = dst

这是使用混合的结果。

你会发现这样好一些,但是还是有问题,如果仔细看就会看到这样的问题

为什么会这样?我们现在是绘制一个 F 然后绘制文字,然后绘制下一个 F 和文字。 我们还有深度缓冲,所以当绘制一个 F 的文字时, 即使使用混合模式保留了背景色,但是深度缓冲还是会更新,当绘制下一个 F 时如果那个 F 的某些部分在之前文字像素的后面,那些部分就不会绘制。

我们遇到了一个使用 GPU 渲染三维时的最难解决的问题,透明出现问题

对与透明渲染常用的解决方法是先渲染不透明的物体,然后按照 z 的顺寻绘制透明物体, 绘制时开启深度检测但是关闭深度缓冲更新。

先将绘制的不透明物体(F)和透明物体区分开(文字),先定义一些东西保存文字的位置。

var textPositions = [];

在循环绘制 F 时保存这些位置

// 记住文字的位置
textPositions.push([fViewMatrix[12], fViewMatrix[13], fViewMatrix[14]]);

绘制 F 前关闭混合模式开启深度缓冲

gl.disable(gl.BLEND);
gl.depthMask(true);

绘制文字开启混合关闭深度缓冲写入

gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);

然后在所有保存的位置绘制文字

textPositions.forEach(function(pos) {
  // 使用 F 的视图位置
  var textMatrix = m4.translate(projectionMatrix,
      pos[0], pos[1], pos[2]);
  // 将文字缩放到需要的大小
  textMatrix = m4.scale(textMatrix, textWidth, textHeight, 1);

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

  gl.bindVertexArray(textVAO);

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

  // 绘制文字
  webglUtils.drawBufferInfo(gl, textBufferInfo);
});

现在它基本上可以了

你可能注意到我并没有向之前提到的进行排序,在这个例子中我们绘制的几乎是透明的文字, 如果排序了也看不出明显的效果,我会将它留在其他文章中去讲。

另一个问题是文字和对应的 'F' 相交了,这其实没有一个明确的解决办法。 如果你正在制作一个 MMO 然后想给每个玩家显示一些持续文字,你可能会将文字显示在头顶。 只需要将它的 +Y 加一些距离,确保它总是在玩家头上方。

你也可以将它移动到相机方向,我们来实现这个吧。由于 'pos' 在视图空间中, 这意味着它和眼睛位置(在视图空间 0,0,0 处)有关,所以如果我们将它单位化然后乘以一个值, 将它移到眼睛方向固定距离。

+// 由于 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 textMatrix = m4.translate(projectionMatrix,
*    viewX, viewY, viewZ);
// 将 F 缩放到需要的大小
// scale the F to the size we need it.
textMatrix = m4.scale(textMatrix, textWidth, textHeight, 1);

这是结果。

你可能还会发现文字边缘的问题。

这个问题是 Canvas 2D API 只生成预乘阿尔法通道的值,当我们上传画布内容为 WebGL 纹理时, WebGL 视图获取没有预乘阿尔法的值,但是由于预乘阿尔法的值缺失阿尔法,所以很难完美转换成非预乘值。

解决这个问题需要告诉 WebGL 不用做反预乘。

gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);

这个告诉 WebGL 提供预乘值到gl.texImage2Dgl.texSubImage2D, 如果像 Canvas 2D 数据本身就是预乘的话,就直接传递到 WebGL。

我们还需要修改混合方法

-gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

旧的方法将源和它的阿尔法通道相乘,就是 SRC_ALPHA 代表的意思。 但是现在我们的纹理数据已经乘了它的阿尔法值,就是预乘的意思。 所以就不需要让 GPU 再做乘法,设置为 ONE 表示乘以 1。

现在边界消失了。

如果你想让文字保持固定大小怎么办?如果你还记得透视投影 种讲到过透视矩阵就是将物体缩放 -Z,以实现近大远小。所以,我们只需缩放 -Z 的期望倍数。

...
// 由于 pos 在视图空间,表示它是一个从眼睛位置出发的一个向量
// 所以沿着向量朝眼睛方向移动一定距离
var fromEye = 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);
// 将 F 缩放到需要的大小
textMatrix = m4.scale(textMatrix, textWidth * scale, textHeight * scale, 1);
...

如果你想给每个 F 绘制不同的文字,就应该给每个 F 创建一个新纹理,然后更新那个 F 的全局变量。

// 创建纹理,每个 F 一个
var textTextures = [
  "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,
].map(function(name) {
  var textCanvas = makeTextCanvas(name, 100, 26);
  var textWidth  = textCanvas.width;
  var textHeight = textCanvas.height;
  var textTex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, textTex);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);
  gl.generateMipmap(gl.TEXTURE_2D);
  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);
  return {
    texture: textTex,
    width: textWidth,
    height: textHeight,
  };
});

然后再渲染时选择纹理

*textPositions.forEach(function(pos, ndx) {

  +// select a texture
  +var tex = textTextures[ndx];

在我们的矩阵计算中使用该纹理大小

  var textMatrix = m4.translate(projectionMatrix,
      viewX, viewY, viewZ);
  // 将 F 缩放到需要的大小
  *textMatrix = m4.scale(textMatrix, tex.width * scale, tex.height * scale, 1);

然后再绘制前设置纹理全局变量

  textUniforms.u_texture = tex.texture;

我们使用黑色绘制的文字,如果使用白色会更有用。那样就可以将文字颜色乘以一个颜色值然后变成任意需要的颜色。

首先改变文字着色器,乘以一个颜色

...
in vec2 v_texcoord;

uniform sampler2D u_texture;
+uniform vec4 u_color;

out vec4 outColor;

void main() {
*   outColor = texture2D(u_texture, v_texcoord) * u_color;
}

然后绘制使用白色绘制文字到画布

textCtx.fillStyle = "white";

创建一些颜色

// 颜色,每个 F 一个
var colors = [
  [0.0, 0.0, 0.0, 1], // 0
  [1.0, 0.0, 0.0, 1], // 1
  [0.0, 1.0, 0.0, 1], // 2
  [1.0, 1.0, 0.0, 1], // 3
  [0.0, 0.0, 1.0, 1], // 4
  [1.0, 0.0, 1.0, 1], // 5
  [0.0, 1.0, 1.0, 1], // 6
  [0.5, 0.5, 0.5, 1], // 7
  [0.5, 0.0, 0.0, 1], // 8
  [0.0, 0.0, 0.0, 1], // 9
  [0.5, 5.0, 0.0, 1], // 10
  [0.0, 5.0, 0.0, 1], // 11
  [0.5, 0.0, 5.0, 1], // 12,
  [0.0, 0.0, 5.0, 1], // 13,
  [0.5, 5.0, 5.0, 1], // 14,
  [0.0, 5.0, 5.0, 1], // 15,
];

绘制时选择颜色

// 设置颜色全局变量
textUniforms.u_color = colors[ndx];

不同颜色

事实上浏览器在开启 GPU 加速时使用了这个技术,它们使用你的 HTML 内容和各种样式生成纹理, 只要内容不变就只需要不停渲染纹理,即使是滚动之类..当然,如果每次都不停的更新内容就会慢一些, 因为重生成纹理并重上传它们到 GPU 是相对较慢的操作。

下篇文章中我们将讲一个频繁更新时较好的处理方法

缩放文字去像素化

你可能会注意到使用固定尺寸例子前的文字,在距离相机近的时候像素化很严重,如何修复?

事实上在三维中缩放二维并不是十分常见,看大多数游戏或三维编辑器就会发现无论相机近还是远, 文字总是固定尺寸。事实上那些文字通常都是绘制在二维而不是三维中,所以即使有人或物体在别的东西的背后, 例如队友在墙后面,你还是可以看到和它相关的文字。

如果你确实需要在三维中缩放二维文字,我不知道有什么简单的办法,我想到了这几个

  • 在不同的分辨率下使用不同的字体和字号生成不同大小的纹理,然后在高分辨率时使用大一点的纹理, 这个技术叫做LODing(使用不同级别的细节)。
  • 另一个是在每一帧渲染文字时使用确切相关的大小,那样就会非常慢。
  • 另一个可能就是使用二维文字的外包几何体。换句话说就是使用三角形代替绘制纹理。 这个方法可行,但是当文字很小的时候渲染的不是很正常,很大的时候可以看到三角形。
  • 还有一个是使用渲染曲线的特殊着色器。 这个非常酷但是超出了我可以在这里解释的范围。
有意见或建议? 在GitHub上提issue.
comments powered by Disqus