PreFound:05 - 写出到屏幕

/ dousha99

在视频里加入文字是一件很奇怪的事情么?

刚刚上期我们启动了 SDL 的渲染流程,并成功地绘制了一些纯色色块输出到视频中。现在我们需要继续添加绘制文字的能力了。

好在(至少现在)我们不需要手动地解析 TrueType/OpenType 字形。FreeTypeHarfBuzz 库已经帮我们完成了解析字形、光栅化以及必要的文字排版的操作。SDL_ttf 库则进一步将整个过程包装成可供 SDL 库使用的形式。目前我们只需要先简单地引入 SDL_ttf 库就可以绘制文字到缓冲区了。

导入

SDL_ttf 库需要单独作为依赖导入:

/* in vcpkg.json */
{
	/* ... */
	"dependencies": [
		/* ... */
		"sdl3-ttf"
	]
	/* ... */
}

也需要单独进行链接:

# in CMakeLists.txt
# ...
find_package(SDL3_ttf CONFIG REQUIRED)
# ...
target_link_libraries(Foundation SDL3_ttf::SDL3_ttf)

以及单独地进行初始化:

// after SDL_Init(.)
if (!TTF_Init()) {
	spdlog::error("failed to initialize sdl_ttf: {}", SDL_GetError());
	return -30;
}

指定字体

要加载一个字体进入程序,只需要调用 TTF_OpenFont(filename, ptsize) 即可。

需要注意到,SDL_ttf 库并不会查询系统内已经安装好的字体。我们必须手动定位字体文件,并指示 SDL_ttf 打开一个具体的字体文件。所以如果像是用 CSS 那样简单地指定 "JetBrains Mono", "Fira Code", "Consolas", "Courier New", monospace 是一定会失败的。

但是「查找系统中安装的字体」这一茬又是心烦:在不同的平台上,这个操作是完全不一样的,御三家各有各的玩法。在 Windows 下,需要通过 GDI 的 EnumFontFamiliesEx 过程来查找字体(或者手动去 C:\Windows\Fonts 文件夹下枚举文件);而在 Linux 下,则需要通过 FontConfig 库来枚举各个安装好的字体——这些字体可能并不总是在 /usr/share/fonts 下,也可能是用户自行配置的路径下;在 macOS 下,则还需要打一个 FFI 到 Objective-C 那边,通过 Cocoa 提供的 NSFontManager 来枚举字体。

那么,解决这个问题的最好方法就是暂时不解决这个问题。我直接提供一个具体的字体文件就可以了!西文的 DejaVu, 中文的文泉驿,以及包含尽可能多字符集的 GNU Unifont 都是可以自由使用的字体。

等会,字体的 ptsize 是什么?怎么换算成像素?

ptsize 是字体的磅数,或者说字号。

SDL_ttf 不确定用的是那套标准,但直觉上应该还是按照桌面排版规则来走的。在桌面排版系统中,1pt 表示 172\frac{1}{72} 英寸,大约 0.35 毫米。如果我们假定像素密度是 72 px/in 的话,那么 1pt 和 1px 就是刚好对应的。

不过,当我们说设置字体大小为 16pt 的时候,我们究竟是设置了什么东西成为 16pt?

em, ex 以及 x-height 等描述具体字的尺寸不同。字体的磅数描述的是「铅字本身」的高度。也就是,这套字体在铸成之后,每个小的铅块的高度。在数字排版中,这个概念被继承了下来,用于表述这个字体的「最大」外框的高度。这里的「最大」带着引号,是因为数字字体的样条完全可以突破这个外框继续绘制(虽然最终绘制的效果可能会随着具体的渲染后端而变化)。

这也意味着一个更为棘手的问题:不同的字体即使调整成同样的磅数,它们的实际高度也可以是不一样的。

different-sizes.png

图 1 Hello, world 在 Calibri, Arial 和宋体下的渲染

字体大小设置为 16pt 后,光标的高度确实是实打实的 4px+16pt/72pt/in96px/in25px 4px + 16pt / 72pt/in * 96px/in \approx 25px (笑)。

算了,先把文字画出来就算成功。

auto* font = TTF_OpenFont("DejaVuSans.ttf", 24);
if (!font) {
	spdlog::error("failed to load font: {}", SDL_GetError());
	return -31;
}

绘制字形

既然之前已经在用 GPU 绘制,那么字形绘制也可以按照 GPU 绘制的方法来:

auto* engine = TTF_CreateRendererTextEngine(renderer);
if (!engine) {
	spdlog::error("failed to create text renderer: {}", SDL_GetError());
	return -32;
}

创建要绘制的字符串对象:

auto* textObj = TTF_CreateText(engine, font, "Hello, world!", 0);
if (!textObj) {
	spdlog::error("failed to create text object: {}", SDL_GetError());
	return -33;
}

然后在渲染循环里提交就可以:

// after SDL_SetRenderTarget(.)
// 设置文字的颜色
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
// 它会自行调用渲染器执行绘制,我们绘制到画布 (10, 10) 的位置上
TTF_DrawRendererText(textObj, 10, 10);

使用完成的对象记得清理:

TTF_DestroyText(textObj);
TTF_DestroyRendererTextEngine(engine);
TTF_CloseFont(font);
TTF_Quit();

文字描边

但是,这样画出来的文字感觉总是缺一点什么。它看起来太「细」了,边缘也过于锐利。要解决这个问题,最简单的方法是做一下文字描边。

虽然 SDL_ttf 库提供了设置文字描边的接口,但是比较糟心的是:文字描边并不是一个字体的后处理值,而是会被烘焙到字体对象中的一个设定。当你调用 TTF_SetFontOutline(.) 函数设置字体对象的描边时,实际上就改变了字体光栅化的结果。这个函数相当于是直接沿着样条法线把字体「加粗」了。

所以,如果要提供描边支持,我们需要创建两个字体。一个是正常的内容,另一个是「加粗」的描边:

auto outlineThickness = 2; // 2px 描边
fontOutline = TTF_OpenFont("DejaVuSans.ttf", 24);
if (!fontOutline) {
	spdlog::error("failed to open font: {}", SDL_GetError());
	return -34;
}

TTF_SetFontOutline(fontOutline, outlineThickness);

这两个文字对象需要各自渲染到自己的材质中(材质对象申请略):

SDL_SetRenderTarget(renderer, textTexture);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
TTF_DrawRendererText(textObj, 0, 0);

SDL_SetRenderTarget(renderer, textOutlineTexture);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
TTF_DrawRendererText(textObj, 0, 0);

再将文字叠到阴影上:

// 因为带描边和不带描边绘制出来的大小不太一样
// 这里需要做一点偏移
const SDL_FRect cascadeRect = {
	outlineThickness, outlineThickness, textTexture->w, textTexture->h
};
SDL_SetRenderTarget(renderer, textOutlineTexture);
SDL_RenderTexture(renderer, textTexture, nullptr, &cascadeRect);

偏移的量是参考字这篇回答得出的。

最后再提交到最终渲染:

const SDL_FRect dstRect = {
	10, 10, textOutlineTexture->w, textOutlineTexture->h
};
SDL_SetRenderTarget(renderer, texture);
SDL_RenderTexture(renderer, textOutlineTexture, nullptr, &dstRect);

这样就可以实现带黑色描边的白色文字啦!

text-rendering-outlined.png

图 2 测试工程中显示的文字(注意和上述代码运行结果不同)

下一步

只渲染静态的内容未免有点无聊,该搞一些动态的内容了。以及,现在所有的东西都跑在一个大 main 里面,需要设计一下怎么把它们拆分出去。

(按:其实拆分这一茬在我实际开始写 PreFound 库的时候就已经做了。博客中主要是为了记录关键过程,所以采用了大 main 跑一切的方案。)

正在加载评论……

发表评论

您的评论将由管理员审核后方可公开显示。

Your comments will be submitted to a human moderator and will only be shown publicly after approval.