终端模拟器的文字绘制¶
背景¶
最近在造鸿蒙电脑上的终端模拟器 Termony,一开始用 ArkTS 的 Text + Span 空间来绘制终端,后来发现这样性能和可定制性比较差,就选择了自己用 OpenGL 实现,顺带学习了一下终端模拟器的文字绘制是什么样的一个过程。
读取字形¶
文本绘制,首先就要从字体文件中读取字形,提取出 Bitmap 来,然后把 Bitmap 绘制到该去的地方。为了提取这些信息,首先用 FreeType 库,它可以解析字体文件,然后计算出给定大小的给定字符的 Bitmap。但是,这个 Bitmap 它只记录字体非空白的部分(准确的说,是 Bounding Box),如下图的 width * height 部分:
(图源:Managing Glyphs - FreeType Tutorial II)
其中 x 轴,应该是同一行的字体对齐的,这样才会看到有高有低的字符出现在同一行,而不是全部上对齐或者下对齐。得到的 Bitmap 是行优先的,也就是说:
- 图中左上角,坐标 (xMin, yMax) 对应 Bitmap 数组的下标是
0
- 图中右上角,坐标 (xMax, yMax) 对应 Bitmap 数组的下标是
width-1
- 图中左下角,坐标 (xMin, yMin) 对应 Bitmap 数组的下标是
width*(height-1)
- 图中右下角,坐标 (xMax, yMax) 对应 Bitmap 数组的下标是
width*(height-1)+width-1
得到这个 Bitmap 后,如果我们不用 OpenGL,而是直接生成 PNG,那就直接进行一次 copy 甚至 blend 就可以把文字绘制上去了。但是,我们要用 OpenGL 的 shader,就需要把 bitmap 放到 texture 里面。由于目前我们用的就是单色的字体,所以它对应只有一个 channel 的 texture。
OpenGL 的 texture,里面也是保存的 bitmap,但它的坐标系统的命名方式不太一样:它的水平向右方向是 U 轴,竖直向上方向是 V 轴,然后它的 bitmap 保存个数也是行优先,但是从 (0, 0) 坐标开始保存像素,然后 U 和 V 的范围都是 0 到 1。
所以,如果我们创建一个 width*height 的单通道 texture,直接把上面的 bitmap 拷贝到 texture 内部,实际的效果大概是:
上图中几个点的坐标以及对应的 bitmap 数组的下标:
- A 点:U = 0,V = 0,对应 bitmap 数组下标
0
- B 点:U = 1,V = 0,对应 bitmap 数组下标
width-1
- C 点:U = 0,V = 1,对应 bitmap 数组下标
width*(height-1)
- D 点:U = 1,V = 1,对应 bitmap 数组下标
width*(height-1)+width-1
所以在向 OpenGL 的 texture 保存 bitmap 的时候,相当于做了一个上下翻转,不过这没有关系,后续在指定三角形顶点的 U V 坐标的时候,保证对应关系即可。
逐个字符绘制¶
有了这个基础以后,就可以实现逐个字符绘制:提前把所有要用到的字符,从字体提取出对应的 Bitmap,每个字符对应到一个 Texture。然后要绘制文字的时候,逐个字符,用对应的 Texture,在想要绘制的位置上,绘制一个字符。为了实现这个目的,写一个简单的 Shader:
// vertex shader
#version 320 es
in vec4 vertex; // xy is position, zw is its texture coordinates
out vec2 texCoors; // output texture coordinates
void main() {
gl_Position.xy = vertex.xy;
gl_Position.z = 0.0; // we don't care about depth now
gl_Position.w = 1.0; // (x, y, z, w) corresponds to (x/w, y/w, z/w), so we set w = 1.0
texCoords = vertex.zw;
}
// fragment shader
#version 320 es
precision lowp float;
in vec2 texCoords;
out vec4 color;
uniform sampler2D text;
void main() {
float alpha = texture(text, texCoords).r;
color = vec4(1.0, 1.0, 1.0, alpha);
}
在这里,我们给每个顶点设置四个属性,包在一个 vec4 中:
- xy:记录了这个顶点的坐标,x 和 y 范围都是 -1 到 1
- zw:记录了这个顶点的 texture 坐标 u 和 v,范围都是 0 到 1
vertex shader 只是简单地把这些信息传递到顶点的坐标和 fragment shader。fragment shader 做的事情是:
- 根据当前点经过插值出来的 u v 坐标,在 texture 中进行采样
- 由于这个 texture 只有单通道,所以它的第一个 channel 也就是
texture(text, texCoords).r
就代表了这个字体在这个位置的 alpha 值 - 然后把 alpha 值输出:
(1.0, 1.0, 1.0, alpha)
,即带有 alpha 的白色
在绘制文字之前,先绘制好背景色,然后通过设置 blending function:
它使得 blending 采用如下的公式:
这里 dest 就是绘制文本前的颜色,src 就是 fragment shader 输出的颜色,也就是 (1.0, 1.0, 1.0, alpha)
。代入公式,就知道最终的结果是:
final.r = 1 * alpha + dest.r * (1 - alpha);
final.g = 1 * alpha + dest.g * (1 - alpha);
final.b = 1 * alpha + dest.b * (1 - alpha);
也就是以 alpha 为不透明度,把白色和背景颜色进行了一次 blend。
如果要设置字体颜色,只需要修改一下 fragment shader:
#version 320 es
precision lowp float;
in vec2 texCoords;
out vec4 color;
uniform sampler2D text;
uniform vec3 textColor;
void main() {
float alpha = texture(text, texCoords).r;
color = vec4(textColor, alpha);
}
此时 src 等于 (textColor.r, textColor.g, textColor.b, alpha)
,经过融合后的结果为:
final.r = textColor.r * alpha + dest.r * (1 - alpha);
final.g = textColor.g * alpha + dest.g * (1 - alpha);
final.b = textColor.b * alpha + dest.b * (1 - alpha);
即最终颜色,等于字体颜色和原来背景颜色,基于 bitmap 的 alpha 值的融合。
解决了颜色,接下来考虑如何设置顶点的信息。前面提到,得到的 bitmap 是一个矩形,而 OpenGL 绘图的基本元素是三角形,因此我们需要拆分成两个三角形来绘图,假如说要绘制一个矩形,它个四个顶点如下:
如果确定左下角 3 这个顶点的坐标是 (xpos, ypos),然后矩形的宽度是 w,高度是 h,考虑到 OpenGL 的坐标系也是向右 X 正方向,向上 Y 正方向,那么这四个顶点的坐标:
- 顶点 1:(xpos, ypos)
- 顶点 2:(xpos + w, ypos)
- 顶点 3:(xpos, ypos + h)
- 顶点 4:(xpos + w, ypos + h)
接下来考虑这些顶点对应的 uv 坐标。首先,我们知道这些顶点对应的 bitmap 的下标在哪里;然后我们又知道这些 bitmap 的下标对应的 uv 坐标,那就每个顶点找一次对应关系:
- 顶点 1:(xpos, ypos),下标是
width*(height-1)
,uv 坐标是 (0, 1) - 顶点 2:(xpos + w, ypos),下标是
width*(height-1)+width-1
,uv 坐标是 (1, 1) - 顶点 3:(xpos, ypos + h),下标是
0
,uv 坐标是 (0, 0) - 顶点 4:(xpos + w, ypos + h),下标是
width-1
,uv 坐标是 (1, 0)
为了绘制这个矩形,绘制两个三角形,分别是 3->1->2 和 3->2->4,一共六个顶点的 (x, y, u, v) 信息就是:
- 3: (xpos , ypos + h, 0, 0)
- 1: (xpos , ypos , 0, 1)
- 2: (xpos + w, ypos , 1, 1)
- 3: (xpos , ypos + h, 0, 0)
- 2: (xpos + w, ypos , 1, 1)
- 4: (xpos + w, ypos + h, 1, 0)
把这些数传递给 vertex shader,就可以画出来这个字符了。
最后还有一个小细节:上述的 xpos 和 ypos 说的是矩形左下角的坐标,但是我们画图的时候,实际上期望的是把字符都画到同一条线上。也就是说,我们指定 origin 的 xy 坐标,然后根据每个字符的 bearingX 和 bearingY 来算出它的矩形的左下角的坐标 xpos 和 ypos:
- xpos = originX + bearingX
- ypos = originY + bearingY - height
至此就实现了逐个字符绘制需要的所有内容。这也是 Text Rendering - Learn OpenGL 这篇文章所讲的内容。
Texture Atlas¶
上面这种逐字符绘制的方法比较简单,但是也有硬伤,比如每次绘制字符,都需要切换 texture,更新 buffer,再进行一次 glDrawArrays 进行绘制,效率比较低。所以一个想法是,把这些 bitmap 拼接起来,合成一个大的 texture,然后把每个字符在这个大的 texture 内的 uv 坐标保存下来。这样,可以一次性把所有字符的所有三角形都传递给 OpenGL,一次绘制完成,不涉及到 texture 的切换。这样效率会高很多。
具体到代码上,也就是分成两步:
- bitmap 的拼接,这一步比较灵活,理想情况下是构造一个比较紧密的排布,但也可以留一些空间,直接对齐到最大宽度/高度的整数倍网格上,然后进行 uv 坐标的计算
- 剩下的,就是在计算顶点信息的时候,用计算好的 uv 坐标,其中 left/right 对应 bitmap 左右两侧的 u 坐标,top/bottom 对应 bitmap 上下两侧的 v 坐标(注意 top 比 bottom 小,因为竖直方向是反的):
- 3: (xpos , ypos + h, left , top )
- 1: (xpos , ypos , left , bottom)
- 2: (xpos + w, ypos , right, bottom)
- 3: (xpos , ypos + h, left , top )
- 2: (xpos + w, ypos , right, bottom)
- 4: (xpos + w, ypos + h, right, top )
此外,在前面的 shader 代码里,字体颜色用的是 uniform,也就是每次调用只能用同一种颜色。修改的方法,就是把它也变成顶点的属性,从 vertex shader 直接传给 fragment shader,替代 uniform 变量。不过由于 vec4 已经放不下更多的维度了,所以需要另外开一个 attribute:
// vertex shader
#version 320 es
in vec4 vertex; // xy is position, zw is its texture coordinates
in vec3 textColor;
out vec2 texCoors; // output texture coordinates
out vec3 fragTextColor; // send to fragment shader
void main() {
gl_Position.xy = vertex.xy;
gl_Position.z = 0.0; // we don't care about depth now
gl_Position.w = 1.0; // (x, y, z, w) corresponds to (x/w, y/w, z/w), so we set w = 1.0
texCoords = vertex.zw;
fragTextColor = textColor;
}
// fragment shader
#version 320 es
precision lowp float;
in vec2 texCoords;
in vec3 fragTextColor;
out vec4 color;
uniform sampler2D text;
void main() {
float alpha = texture(text, texCoords).r;
color = vec4(fragTextColor, alpha);
}
背景和光标绘制¶
接下来回到终端模拟器,它除了绘制字符,还需要绘制背景颜色和光标。前面在绘制字符的时候,只把 bounding box 绘制了出来,那么剩下的空白部分是没有绘制的。但是终端里,每一个位置的背景颜色都可能不同,所以还需要给每个位置绘制对应的背景颜色。这里有两种做法:
第一种做法是,把前面每个字符的 bitmap 扩展到终端里一个固定的位置的大小,这样每次绘制的矩形,就是完整的一个位置的区域,这个时候再去绘制背景颜色,就比较容易了:修改 vertex shader 和 fragment shader,在内部进行一次 blend:color = vec4(fragTextColor * alpha + fragBackgroundColor * (1.0 - alpha), 1.0)
,相当于是丢掉了 OpenGL 的 blend function,自己完成了前景和后景的绘制。
但这个方法有个问题:并非所有的字符的 bitmap 都可以放到一个固定大小的矩形里的。有一些特殊字符,要么长的太高,要么在很下面的位置。后续可能还有更复杂的需求,比如 CJK 和 Emoji,那么字符的宽度又不一样了。所以这个时候导出了第二种做法:
- 第一轮,先绘制出终端每个位置的背景颜色
- 第二轮,再绘制出每个位置的字符,和背景进行融合
这时候 shader 没法自己做 blend,所以这考虑怎么用 blend function 来实现这个 blend 的计算。首先,要考虑我们最终需要的结果是:
final.r = textColor.r * alpha + dest.r * (1 - alpha);
final.g = textColor.g * alpha + dest.g * (1 - alpha);
final.b = textColor.b * alpha + dest.b * (1 - alpha);
final.a = textColor.a * alpha + dest.a * (1 - alpha);
由于是 OpenGL 做的 blending,我们需要用 OpenGL 自带的 blending mode 来实现上述公式。OpenGL 可以指定 RGB 的 source 和 dest 的 blending 方式,比如:
- GL_ONE:乘以 1 的系数
- GL_ONE_MINUS_SRC_ALPHA:乘以 (1 - source.a) 的系数
根据这个,就可以想到,设置 source = textColor * alpha
,设置 source 采用 GL_ONE 方式,dest 采用 GL_ONE_MINUS_SRC_ALPHA 模式,那么 OpenGL 负责剩下的 blending 工作 final = source * 1 + dest * (1 - source.a)
:
final.r = source.r * 1.0 + dest.r * (1 - source.a) = textColor.r * alpha + dest.r * (1 - alpha);
final.g = source.g * 1.0 + dest.g * (1 - source.a) = textColor.g * alpha + dest.g * (1 - alpha);
final.b = source.b * 1.0 + dest.b * (1 - source.a) = textColor.b * alpha + dest.b * (1 - alpha);
final.a = source.a * 1.0 + dest.a * (1 - source.a) = textColor.a * alpha + dest.a * (1 - alpha);
正好实现了想要的计算公式。这个方法来自于 Text Rendering - WebRender。有了这个推导后,就可以分两轮,完成终端里前后景的绘制了。
目前 Termony 用的就是这种实现方法:
- 首先把不同字重的各种字符的 bitmap 拼在一起,放在一个 texture 内部
- 使用两阶段绘制,第一阶段
注:如果不考虑 textColor 的 alpha 值,也可以在 source 使用 GL_SRC_ALPHA,此时设置 source = vec4(textColor.rgb, alpha)
,这样 final.r = source.r * source.a + dest.r * (1 - source.a) = textColor.r * alpha + dest.r * (1 - alpha)
,结果是一样的,不过这个时候 final 的 alpha 值等于 source.a * source.a + dest.a * (1 - source.a)
是 alpha 和 dest.a 经过 blend 以后的结果,如果不用它就无所谓。
在鸿蒙上使用 OpenGL 渲染¶
最后再简单列举一下,在鸿蒙上用 OpenGL 渲染都需要哪些事情:
首先,在 ArkTS 中,插入一个 XComponent,然后在 XComponentController 的回调函数中,通知 native api:
import testNapi from 'libentry.so';
class MyXComponentController extends XComponentController {
onSurfaceCreated(surfaceId: string): void {
hilog.info(DOMAIN, 'testTag', 'onSurfaceCreated surfaceId: %{public}s', surfaceId);
testNapi.createSurface(BigInt(surfaceId));
}
onSurfaceChanged(surfaceId: string, rect: SurfaceRect): void {
hilog.info(DOMAIN, 'testTag', 'onSurfaceChanged surfaceId: %{public}s rect: %{public}s', surfaceId, JSON.stringify(rect));
testNapi.resizeSurface(BigInt(surfaceId), rect.surfaceWidth, rect.surfaceHeight);
}
onSurfaceDestroyed(surfaceId: string): void {
hilog.info(DOMAIN, 'testTag', 'onSurfaceDestroyed surfaceId: %{public}s', surfaceId);
testNapi.destroySurface(BigInt(surfaceId))
}
}
@Component
struct Index {
xComponentController: XComponentController = new MyXComponentController();
build() {
// ...
XComponent({
type: XComponentType.SURFACE,
controller: this.xComponentController
})
}
}
native 部分需要实现至少两个函数:createSurface 和 resizeSurface。其中主要的工作在 CreateSurface 中完成,ResizeSurface 会在窗口大小变化的时候被调用。
CreateSurface 要做的事情:
读取 surface id:
size_t argc = 1;
napi_value args[1] = {nullptr};
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
int64_t surface_id = 0;
bool lossless = true;
napi_status res = napi_get_value_bigint_int64(env, args[0], &surface_id, &lossless);
assert(res == napi_ok);
创建 OHNativeWindow:
OHNativeWindow *native_window;
OH_NativeWindow_CreateNativeWindowFromSurfaceId(surface_id, &native_window);
assert(native_window);
创建 EGLDisplay:
EGLNativeWindowType egl_window = (EGLNativeWindowType)native_window;
EGLDisplay egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
assert(egl_display != EGL_NO_DISPLAY);
初始化 EGL:
EGLint major_version;
EGLint minor_version;
EGLBoolean egl_res = eglInitialize(egl_display, &major_version, &minor_version);
assert(egl_res == EGL_TRUE);
选择 EGL 配置:
const EGLint attrib[] = {EGL_SURFACE_TYPE,
EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE,
EGL_OPENGL_ES2_BIT,
EGL_RED_SIZE,
8,
EGL_GREEN_SIZE,
8,
EGL_BLUE_SIZE,
8,
EGL_ALPHA_SIZE,
8,
EGL_DEPTH_SIZE,
24,
EGL_STENCIL_SIZE,
8,
EGL_SAMPLE_BUFFERS,
1,
EGL_SAMPLES,
4, // Request 4 samples for multisampling
EGL_NONE};
const EGLint max_config_size = 1;
EGLint num_configs;
EGLConfig egl_config;
egl_res = eglChooseConfig(egl_display, attrib, &egl_config, max_config_size, &num_configs);
assert(egl_res == EGL_TRUE);
创建 EGLSurface:
创建 EGLContext:
EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
EGLContext egl_context = eglCreateContext(egl_display, egl_config, EGL_NO_CONTEXT, context_attributes);
在当前线程启用 EGL:
在这之后就可以用 OpenGL 的各种函数了。OpenGL 绘制完成以后,更新到窗口上:
在 ResizeSurface 中,主要是更新 glViewport,让它按照新的 surface 大小来绘制。