简体中文
简体中文
文字渲染器的设计与实现

文字渲染器的设计与实现

technical

引言

文字渲染一直是 Galacean Engine 在 2D 方面缺失的一个重要的基础能力,开发者在 H5 的项目中只能利用 DOM元素 来实现文字渲染,整个互动项目的开发流程比较割裂。为了让开发者的开发体验不再割裂,完成引擎 2D 基础功能的闭环,我们在引擎 v0.7 版本新增了文字渲染器,支持基础的系统字体的渲染,包括大小、颜色、加粗、斜体等样式设置。另外,也支持多行显示,并支持各种对齐方式。

技术方案选型

目前在 Web 中实现文字渲染主要有几种方案:

  • Dom 方案:利用 dom 元素来绘制文字
  • Canvas 方案:先在一个离屏 Canvas 的 2d 上下文中绘制文字,并拿到纹理,最后放到 WebGL 中渲染
  • BMFont 方案:提前将文字制作为纹理,需要显示的取出来并放到 WebGL 中渲染
  • SDF 方案:提前将文字制作为纹理,但存储的是文字边缘距离,最后通过特定的 shader 渲染

以上提到的各种方案在 WebGL 渲染中对比如下:

优点缺点
Dom 方案
  • 文字样式丰富
  • 文字不需要额外纹理
  • 实现简单 |
  • 开发流程割裂
  • 文字位置摆放比较麻烦
  • 没法在 WebGL 渲染的元素之间显示 | | Canvas 方案 |
  • 文字样式丰富
  • 实现不复杂 |
  • 文字更新需要实时生成新的纹理,文字频繁更新并且文字量大的时候性能压力大 | | BMFont 方案 |
  • 文字集的纹理提前生成的,不需要实时计算
  • 可以支持各种艺术字样式
  • 在各个浏览器上显示一致 |
  • 需要用到的文字需要提前规划好,新增文字需要重新导出,并且文字过多会导致纹理过大,超出 2048 移动设备上基本不支持 | | SDF 方案 |
  • 文字集的纹理提前生成的,不需要实时计算
  • 生成的纹理可以很小
  • 文字放大也非常清晰,不容易糊
  • 实现内外描边等功能非常方便 |
  • 实现成本相对高,并且需要专门的 shader 来渲染 |

结论 我们的核心诉求是解决开发者割裂的问题,先解决从无到有的问题。因此 Dom 方案直接 Pass,其余方案各有适合自己的使用场景,我们计划分 3 个版本来实现:

  • 第一个版本:支持系统文字的渲染
  • 第二个版本:支持艺术字的渲染
  • 第三个版本:支持 SDF 字体的渲染

设计与实现

本文作为文字渲染器的第一篇,将介绍第一个版本的设计与实现。系统文字的渲染实现方案我们采用了 Canvas 方案,我们以在屏幕上显示 “Hello World” 为例,大致流程如下:

image.png

上图的流程中省略不少细节,本文会结合内部实现流程,按照上图顺序逐步分享实现细节。

第一步:文字渲染器 (TextRenderer) 的设计

image.png

首先我们以组件 (TextRenderer) 的形式让开发者在项目中渲染系统文字,TextRenderer 组件的使用如下:

const textRenderer = entity.addComponent(TextRenderer);
textRenderer.xxx = yyy; // 通过设置组件的各属性来设置文本以及样式等

TextRenderer 提供了设置显示文本、文本颜色、字体、大小、样式、行间距、水平和垂直方向的对齐方式、是否换行、超出设置的高是否溢出等:

export class TextRenderer extends Renderer {
  /**
    * Rendering color for the Text.
    */
  get color(): Color;
  set color(value: Color);
  /**
    * Rendering string for the Text.
    */
  get text(): string;
  set text(value: string);
  /**
    * The width of the TextRenderer (in 3D world coordinates).
    */
  get width(): number;
  set width(value: number);
  /**
    * The height of the TextRenderer (in 3D world coordinates).
    */
  get height(): number;
  set height(value: number);
  /**
    * The font of the Text.
    */
  get font(): Font;
  set font(value: Font);
  /**
    * The font size of the Text.
    */
  get fontSize(): number;
  set fontSize(value: number);
  /**
    * The style of the font.
    */
  get fontStyle(): FontStyle;
  set fontStyle(value: FontStyle);
  /**
    * The space between two lines (in pixels).
    */
  get lineSpacing(): number;
  set lineSpacing(value: number);
  /**
    * The horizontal alignment.
    */
  get horizontalAlignment(): TextHorizontalAlignment;
  set horizontalAlignment(value: TextHorizontalAlignment);
  /**
    * The vertical alignment.
    */
  get verticalAlignment(): TextVerticalAlignment;
  set verticalAlignment(value: TextVerticalAlignment);
  /**
    * Whether wrap text to next line when exceeds the width of the container.
    */
  get enableWrapping(): boolean;
  set enableWrapping(value: boolean);
  /**
    * The overflow mode.
    */
  get overflowMode(): OverflowMode;
  set overflowMode(value: OverflowMode);
}

这里有几个属性需要拿出来讲讲,首先是 font,font 是字体资产,决定字体的基本信息,比如当前版本的字体资产中的 name 字段就可以当作 fontFamily 使用,决定了我们使用系统中的哪个字体。再后面的版本中,我们会持续引入 BMFont 和 SDFFont,这部分也只是需要引擎底层添加 2 个新的字体资源即可,结构如下:

image.png

当文本过长并且希望多行显示的时候,可以设置 enableWrapping = true,并且设置 width。如果 width < 0,将什么也不显示,如果 width > 0,将按照下面的规则拆分为多行:

第二步:将文本绘制到离屏 Canvas

image.png

在每帧调用 TextRenderer 的 _render 的时候会先检测文本或者样式是否有更新,更新状态是通过脏标记实现的,代码如下:

const isTextureDirty = this._isContainDirtyFlag(DirtyFlag.Property);
if (isTextureDirty) {
  // 更新文本,第 2、3 步都在这里
  this._updateText();
  this._setDirtyFlagFalse(DirtyFlag.Property);
}

如果检测到文本或者样式有更新的时候,会执行更新函数。在 _updateText 函数中,主要做了 3 件事情:测算文本的最终显示信息、往离屏 Canvas 更新内容、得出最新的纹理。在第二步中主要是完成前面两件事。

预算文本的最终显示信息,是结合文本内容、样式以及换行等规则,推算出绘制到离屏 Canvas 上所需要的信息,具体实现放在 TextUtils.measureText 中实现,返回信息的结构如下:

export interface TextMetrics {
  width: number; // 需要 Canvas 的宽度
  height: number; // 需要 Canvas 的高度
  lines: Array<string>; // 每行文本的数组
  lineWidths: Array<number>; // 每行文本的宽度
  lineHeight: number; // 每行文本的高度,包括行间距了
}

拿到 TextMetrics 后,就可以把最新的文本内容往离屏 Canvas 上绘制了,核心代码如下:

public static updateText(
  textMetrics: TextMetrics,
  fontStr: string,
  horizontalAlignment: TextHorizontalAlignment,
  verticalAlignment: TextVerticalAlignment
): void {
  const { canvas, context } = TextUtils.textContext();
  const { width, height } = textMetrics;
  // 重置 canvas 的宽高
  canvas.width = width;
  canvas.height = height;
  // 清理 canvas
  context.font = fontStr;
  context.clearRect(0, 0, width, height);
  // 设置文本绘制相关信息
  context.textBaseline = "middle";
  context.fillStyle = "#fff";
 
  // draw lines.
  const { lines, lineHeight, lineWidths } = textMetrics;
  const halfLineHeight = lineHeight * 0.5;
  for (let i = 0, l = lines.length; i < l; ++i) {
    const lineWidth = lineWidths[i];
    const pos = TextUtils._tempVec2;
    // 根据对齐规则计算 pos
    TextUtils._calculateLinePosition(width, height, lineWidth, lineHeight, i, l, horizontalAlignment, verticalAlignment, pos);
    const { x, y } = pos;
    // 超出 canvas 范围的就不绘制了
    if (y + lineHeight >= 0 && y < height) {
      // 绘制文本
      context.fillText(lines[i], x, y + halfLineHeight);
    }
  }
}

第三步:将 Canvas 数据转为纹理

image.png

在第二步中,我们得到的 TextMetrics 中的画布宽高并不是很精准的,而是一个估算值,估算值会比实际文本显示区域大,也就是说,此时的画布里面文本内容周边是有空白像素的,我们以下图来说明:

image.png

上图中,假设黑色部分为画布估算大小,红色部分才是实际我们需要的,如果直接将画布数据转为纹理,那么纹理其实是有浪费的,如果文字渲染器多了,积少成多,对内存不太友好。因此我们的优化方式是转为纹理前先对估算的画布进行裁剪,获取红色部分的数据转为纹理,整体流程如下:

上述中画布裁剪算法的实现参考: https://gist.github.com/remy/784508

第四步:渲染到显示屏幕上

image.png

最后一步是最简单的,直接复用了之前 SpriteRenderer 的渲染,文本渲染和精灵渲染,从实现上来说,唯一差异就是纹理来源了,文本渲染的纹理来自于通过用户的相关输入来生成,精灵渲染的纹理来自于用户直接加载的图片资源。

总结

这个版本中,主要解决的是文字渲染从无到有的问题,已经可以满足一些简单的文字显示相关需求,如果要做一些复杂的功能,比如弹幕这类的,内存方面可能会有压力,这块的优化方式主要是提供基于单个字符的缓存,这部分我们会在下个版本中支持,对于开发者来说,这部分是无感的。

Galacean Logo
Make fantastic web apps with the prospective
technologies and tools.
Copyright © 2024 Galacean
All rights reserved.