终端模拟器的文字绘制¶ 最近在造鸿蒙电脑上的终端模拟器 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 内部,实际的效果大概是:
V ^ | C D | | A------B--->U 上图中几个点的坐标以及对应的 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:
glEnable ( GL_BLEND ); glBlendFunc ( GL_SRC_ALPHA , GL_ONE_MINUS_SRC_ALPHA ); 它使得 blending 采用如下的公式:
final = src * src . alpha + dest * ( 1 - src . alpha ); 这里 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.rgb * alpha + fragBackgroundColor.rgb * (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 = 1.0 ; 由于是 OpenGL 做的 blending,我们需要用 OpenGL 自带的 blending mode 来实现上述公式。OpenGL 可以指定 RGB 的 source 和 dest 的 blending 方式,比如:
GL_ONE:乘以 1 的系数 GL_ONE_MINUS_SRC_ALPHA:乘以 (1 - source.a) 的系数 根据这个,就可以想到,设置 source = vec4(textColor.rgb * alpha, alpha),设置 source 采用 GL_ONE 方式,dest 采用 GL_ONE_MINUS_SRC_ALPHA 模式,那么 OpenGL 负责剩下的 blending 工作 final = source * 1 + dest * (1 - source.a)(要求 dest.a = 1.0):
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 ) = alpha + 1.0 * ( 1 - alpha ) = 1.0 ; 正好实现了想要的计算公式。这个方法来自于 Text Rendering - WebRender 。有了这个推导后,就可以分两轮,完成终端里前后景的绘制了。
目前 Termony 用的就是这种实现方法:
首先把不同字重的各种字符的 bitmap 拼在一起,放在一个 texture 内部 使用两阶段绘制,第一阶段 注:如果在 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 以后的结果,不再是 1.0,如果不用它就无所谓。上面这种 vec4(textColor.rgb * alpha, alpha) 的计算方法,叫做 premultiplied alpha,也就是预先把 alpha 乘到颜色项里,可以方便后续的计算。
在鸿蒙上使用 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:
EGLSurface egl_surface = eglCreateWindowSurface ( egl_display , egl_config , egl_window , NULL ); 创建 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:
eglMakeCurrent ( egl_display , egl_surface , egl_surface , egl_context ); 在这之后就可以用 OpenGL 的各种函数了。OpenGL 绘制完成以后,更新到窗口上:
eglSwapBuffers ( egl_display , egl_surface ); 在 ResizeSurface 中,主要是更新 glViewport,让它按照新的 surface 大小来绘制。