文字渲染一直是 Galacean Engine 在 2D 方面缺失的一个重要的基础能力,开发者在 H5 的项目中只能利用 DOM元素 来实现文字渲染,整个互动项目的开发流程比较割裂。为了让开发者的开发体验不再割裂,完成引擎 2D 基础功能的闭环,我们在引擎 v0.7 版本新增了文字渲染器,支持基础的系统字体的渲染,包括大小、颜色、加粗、斜体等样式设置。另外,也支持多行显示,并支持各种对齐方式。
目前在 Web 中实现文字渲染主要有几种方案:
以上提到的各种方案在 WebGL 渲染中对比如下:
优点 | 缺点 | |
---|---|---|
Dom 方案 |
结论 我们的核心诉求是解决开发者割裂的问题,先解决从无到有的问题。因此 Dom 方案直接 Pass,其余方案各有适合自己的使用场景,我们计划分 3 个版本来实现:
本文作为文字渲染器的第一篇,将介绍第一个版本的设计与实现。系统文字的渲染实现方案我们采用了 Canvas 方案,我们以在屏幕上显示 “Hello World” 为例,大致流程如下:
上图的流程中省略不少细节,本文会结合内部实现流程,按照上图顺序逐步分享实现细节。
首先我们以组件 (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 个新的字体资源即可,结构如下:
当文本过长并且希望多行显示的时候,可以设置 enableWrapping = true,并且设置 width。如果 width < 0,将什么也不显示,如果 width > 0,将按照下面的规则拆分为多行:
在每帧调用 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);
}
}
}
在第二步中,我们得到的 TextMetrics 中的画布宽高并不是很精准的,而是一个估算值,估算值会比实际文本显示区域大,也就是说,此时的画布里面文本内容周边是有空白像素的,我们以下图来说明:
上图中,假设黑色部分为画布估算大小,红色部分才是实际我们需要的,如果直接将画布数据转为纹理,那么纹理其实是有浪费的,如果文字渲染器多了,积少成多,对内存不太友好。因此我们的优化方式是转为纹理前先对估算的画布进行裁剪,获取红色部分的数据转为纹理,整体流程如下:
上述中画布裁剪算法的实现参考: https://gist.github.com/remy/784508
最后一步是最简单的,直接复用了之前 SpriteRenderer 的渲染,文本渲染和精灵渲染,从实现上来说,唯一差异就是纹理来源了,文本渲染的纹理来自于通过用户的相关输入来生成,精灵渲染的纹理来自于用户直接加载的图片资源。
这个版本中,主要解决的是文字渲染从无到有的问题,已经可以满足一些简单的文字显示相关需求,如果要做一些复杂的功能,比如弹幕这类的,内存方面可能会有压力,这块的优化方式主要是提供基于单个字符的缓存,这部分我们会在下个版本中支持,对于开发者来说,这部分是无感的。