[03] What Fonts Do I Have? — A Cross-Platform Solution

“Every increased possession adds increased anxiety on to our lives.”

— Randy Alcorn

你可以在 Game Engine 0 to 1 标签下浏览该系列的所有文章。

本章主要介绍 DungineX 的衍生项目 sysfonts,本章对应🏷️0.1.1。虽然不涉及 DungineX,但其也有一些更新,可以在 commit 49ad28f 预览,暂未合并至主分支。

虽然隔了很久才更新,但是本章并不涉及 DungineX,更确切来说,不涉及引擎的功能。由于本人不幸购买了 MacBook,导致集齐了 Windows,Linux(WSL)和 MacOS 三大主流桌面操作系统,从而使得跨平台这一需求不得不得到一定程度的重视。

好消息是,SDL 已经为我们完成了跨平台的兼容,我们无需任何修改即可实现在不同平台的编译运行!然而,不幸的是,由于渲染文字需要获取字体文件,在 Renderer/Font.cpp 中,我们直接访问了 Windows 系统的字体目录 C:\Windows\Fonts,使得 DungineX 还是无法跨平台运行。

除了这一功能上的兼容性问题外,在编译上也存在一些问题。对于 Windows 平台,默认使用 MSVC。MSVC 虽好,但有时过于仁慈,和 GCC,Clang(甚至 Apple Clang)的兼容性不是很好,因此在其他平台可能无法通过编译。当然,WinMain 就不必多说了,其他比较典型的例子是 Windows 平台下的 dllexportdllimport,以及 __debugbreak。因此还需要一些宏定义来进行不同平台的兼容。

编译问题很好解决,毕竟目前的代码还算“整洁”,需要额外关注的是如何实现字体的加载。当然,已经有 SDL_ttfSDL_FontCache 帮加载以及渲染字体了,所以问题在于如何找到系统内的字体文件。这看似是一个很简单的事情,比如在 Microsoft Word 里我们可以在下拉菜单里任选文字,但居然在 GitHub 上没有找到任何开源的实现?

或许还是有的,只是我没有找到?

由于这一需求与游戏引擎没有必然的联系,所以写了一个独立的库作为功能的扩充。


sysfonts

sysfonts — A lightweight cross-platform C library to list installed system fonts.

Fonts

在正式介绍 sysfonts 之前,先来简单聊一聊字体。

目前来说,应用最广泛的还是 TrueType.ttf)字体,通常来说系统里安装的也都是 TrueType 字体。当然, 在 Web 开发中,为了更好的兼容性,可能会使用一些其他格式,例如这里是对 Lucida Handwriting 字体以及其变体的引用。

1
2
3
4
5
6
7
8
9
10
11
12
@font-face {
font-family: 'Lucida Handwriting';
src: url('fonts/LucidaHandwriting-Italic.eot');
src: url('fonts/LucidaHandwriting-Italic.ttf') format('truetype'),
url('fonts/LucidaHandwriting-Italic.eot?#iefix') format('embedded-opentype'),
url('fonts/LucidaHandwriting-Italic.woff2') format('woff2'),
url('fonts/LucidaHandwriting-Italic.woff') format('woff'),
url('fonts/LucidaHandwriting-Italic.svg#LucidaHandwriting-Italic') format('svg');
font-weight: normal;
font-style: italic;
font-display: swap;
}

虽然这么花里胡哨引用了一大堆格式,但是似乎其实只需要 TrueType 一种就可以实现全平台兼容了。比如这个 Lucida Handwriting,如果不添加 TrueType,至少在本文发布的时刻,iOS 系统的浏览器(Microsoft Edge 和 Safari)是无法正常渲染的。

对于桌面操作系统来说,渲染文字在平常不过了, 因此都会在系统中自带很多字体,也就是在一些软件,比如 Microsoft Word 中你能选择的字体。但是,为了让操作系统,以及操作系统上运行的软件识别这些字体,需要将字体安装在特定的位置。比如,Windows 系统所有的字体都在 C:\Windows\Fonts 目录下。

进一步地,字体还有很多样式,字体的属性主要是 font-weightfont-style,其不像 font-size 一样能够简单通过缩放实现,因此实际上对应了不同的字体文件。参考上一章中的文本绘制一节,对于不同的字体属性,我们其实加载了同一个字符在不同字体文件中的 Glyph。例如,我们熟悉的 Consolas 字体,如果你从 C:\Windows\Fonts 中将其复制出来,你会得到四个字体文件,对应了 Bold 和 Italic 样式的排列组合。

image-20251020183551694

当然,你可能也会注意到,有时候我们会有删除线或者下划线,这些就属于渲染效果,不是字体本身的属性了。

因此我们会有 Font Family 和 Font Face 的区别。一个 Font Family 包含了一系列不同样式的字体。所以在 DungineX 中,我们也希望有这样的支持,即加载整个 Font Family,并提供基本的文字样式。

What do we want?

写一个库,当然首先考虑的是要做什么,实现什么功能。对于 DungineX 来说,我们当然可以随引擎附带一个 .ttf 文件,甚至打包进可执行文件。但是我们还是希望减少发布产物,因此希望能够直接使用系统自带的字体。此外,目前 DungineX 需要知道 TrueType 字体的具体文件名,而字体文件名可能不完全与字体名称相对应,因此我们还想让 DungineX 能够直接通过字体名称进行加载。当然,本质是维护字体名称到字体文件路径的映射。

因此,对于这个库,我们的最终目的,是在不同平台上,提供某种接口,从而获得系统所有字体、样式以及其对应的路径。

A cross-platform library

在开始正式编写代码之前,可能还需要一些考虑。包括用什么语言,定义什么样的接口,诸如此类。

C++ 可以自然地调用 C,但反之不亦然,而且可以预见的是,这个库主要是调用操作系统的 API,而操作系统接口都是 C,所以也没有特别使用 C++ 的必要性,所以 sysfonts 采用 C 实现。至于项目管理,那还是 CMake。当然这些都是很自然的事情,我在这里主要想说一些跨平台代码编写上的一些事项。

虽然这里是再说 sysfonts,但是之后 DungineX 也要进行跨平台改造,因此同样适用于 DungineX,故之后不会再具体解释 DungineX 的相关改动。

Platform-aware CMake

首先要明确的是,虽然大部分工具都对不同平台有很好的兼容性,但是不同平台的差异是不可避免的。对于 CMake,自动生成的编译、链接都没有问题,但是手动添加选项时就需要对编译器进行一定的区分。一个常见的场景就是编译警告等级的设置,具体见 cmake/utils.cmake

1
2
3
4
5
6
7
8
9
10
11
12
function(sf_enable_warnings target_name)
target_compile_options(
${target_name}
PRIVATE $<$<OR:$<CXX_COMPILER_ID:Clang>,$<CXX_COMPILER_ID:AppleClang>,$<CXX_COMPILER_ID:GNU>>:
-Wall
-Wextra
-Wconversion
-pedantic
-Werror
-Wfatal-errors>
$<$<CXX_COMPILER_ID:MSVC>:/W4>)
endfunction()

此外,CMake 也提供了不同平台的标志,可以直接在 if 中使用。之后我们会看到具体的应用场景。

1
2
3
4
5
6
7
8
9
if(WIN32)
# ...
elseif(LINUX)
# ...
elseif (APPLE)
# ...
else()
# ...
endif()

Platform-aware C

接下来,就是如何让我们的代码知道自己正在哪个平台上被编译了。当然我们可以通过在 CMake 中检测,并针对不同平台添加不同宏定义的方式,但是 CMake 不是唯一的选择,我们需要让代码本身知道具体的平台。

其实,不同平台的编译器其实已经内置了相关宏,所以还是可以很轻松的识别出自身所在的平台。同时,我们可以进一步定义我们内部的宏。

1
2
3
4
5
6
7
8
9
#if (defined(_WIN32) || defined(_WIN64))
# define SF_PLATFORM_WINDOWS
#elif (defined(__linux__))
# define SF_PLATFORM_LINUX
#elif (defined(__APPLE__) || defined(__MACH__))
# define SF_PLATFORM_MACOS
#else
# error "Unsupported platform"
#endif

或许你注意到了,这里的命名风格参考了 SDL。

如果你还想实现动态链接库的支持,那么你还需要处理一下符号的导入导出问题。在 Windows 下,符号默认是隐藏的,所以需要显示地指定 dllexport

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifdef SF_PLATFORM_WINDOWS
# ifdef SF_EXPORT
# define SF_API __declspec(dllexport)
# elif defined(SF_IMPORT)
# define SF_API
# else
# define SF_API
# endif
#elif __GNUC__ >= 4
# define SF_API __attribute__((visibility("default")))
#else
# define SF_API
#endif

之前在 More about shared libary 中已经提到了,如果不涉及变量的导出的话,其实是不需要 dllimport 的。

此外,这里还有一个 CMake 的技巧,当我们构建动态链接库时,可以通过 PRIVATEINTERFACE 的组合,实现精确的宏定义作用域。PRIVATE 指定宏只作用于当前目标,而 INTERFACE 指定宏只作用于链接它的目标。

1
2
target_compile_definitions(${target_name} PRIVATE SF_EXPORT)
target_compile_definitions(${target_name} INTERFACE SF_IMPORT)

最后,作为一个 C 库,考虑到 C++ 用户,我们可以添加 extern "C",确保我们的模块以 C 的方式进行编译。

1
2
3
4
5
6
7
8
9
10
11
#ifdef __cplusplus
# define SF_EXTERN_C extern "C"
# define SF_EXTERN_C_BEGIN \
extern "C" \
{
# define SF_EXTERN_C_END }
#else
# define SF_EXTERN_C
# define SF_EXTERN_C_BEGIN
# define SF_EXTERN_C_END
#endif

接口定义

既然我们已经实现了基础的项目结构,我们就可以开始设计和实现了。

首先,是我们最关心的数据,对于一个字体,我们希望得到它的名称,样式和具体文件路径。注意到样式并不是一个简单的枚举,如果你具体查看每个字体,你会发现样式不知有 Bold 和 Italic,还有 Book,Condensed 等等。

1
2
3
4
5
6
typedef struct _SF_FontInfo
{
const char* family; // font family name, e.g. "Arial"
const char* style; // font style, e.g. "Regular", "Bold Italic"
const char* path; // full path to the font file
} SF_FontInfo;

对于核心的需求,我们需要让用户知道所有的字体,一个直观的做法是返回一个列表。但是对于 C 来说,资源管理会很麻烦。SF_FontInfo 很可能包含动态分配的内存,因此这样做要求用户随后手动释放资源,从而引来不必要的麻烦。而且即使在库中提供一个封装好的列表,用户大概率也不会用,尤其是 C++ 用户。因此,我们可以将资源管理的任务转交给用户。

基于这样的想法,我们可以在库内部遍历所有字体,并对每一个字体调用用户提供的一个回调函数,因此我们得到了 sysfonts.h。当然为了保证一定的灵活性,我们允许用户传递 context 作为上下文。

1
2
3
typedef int (*SF_FontsEnumCallback)(const SF_FontInfo* font, void* context);

SF_API int SF_EnumFonts(SF_FontsEnumCallback callback, void* context);

接下来,我们就可以在不同平台实现这个接口了。不过,怎么实现不同平台编译不同的源代码?其实很简单,只需要用对应平台的宏将实现包裹起来即可。

1
2
3
4
5
6
7
#ifdef SF_PLATFORM_WINDOWS
// Windows implementation
#endif

#ifdef SF_PLATFORM_LINUX
// Linux implementation
#endif

很奇怪,这里我们居然最后介绍 Windows,为什么呢?

Linux Implementation

具体实现见 src/impl/SF_linux.c

虽然字体并不意味着一定要有图形化界面,但是这个项目的初衷还是希望有图形化界面的。对于 Linux,其实现使用到了 fontconfig,因此你可能需要安装额外的依赖。

1
sudo apt install -y libfontconfig1-dev

Fontconfig 是 Linux 下的一个字体管理库,提供了一些便于访问系统字体缓存的 API。具体关于 Fontconfig 可以参考官方文档。

不得不说,确实是这三个平台里最正常的写法了。其中最核心的是获取指定属性的字体列表操作,其他细节不再展示。

1
2
3
FcPattern* pattern = FcPatternCreate();
FcObjectSet* objects = FcObjectSetBuild(FC_FAMILY, FC_STYLE, FC_FILE, NULL);
FcFontSet* fonts = FcFontList(NULL, pattern, objects);

MacOS Implementation

具体实现见 src/impl/SF_macos.c

怎么说呢,第一次写 MacOS 平台的 C 代码,深受震撼。真的是,很 Objective 了。命名风格也是独特,居然不用缩写,虽然能一下子看懂,但是整个逻辑总觉得很别扭。只能说,不愧是 Apple。

别扭的地方在于,明明一行就拿到了所有 Font Descriptors,但需要几十行来遍历这个数组。很多地方只能拿到动态分配来的 Copy,所以还得手动释放。

1
CFArrayRef fontDescriptors = CTFontManagerCopyAvailableFontFamilyNames();

Windows Implementation

具体实现见 src/impl/SF_windows.c

之所以最后介绍 Windows,是因为它实在是特殊。虽说 MacOS 很······,但是至少完成了任务,Windows 这里虽然简单,但是满足不了我们的需求。

我们想尽量轻量级,所以 Windows 上最合适的方法就是读 Registry(注册表)。Windows 的字体缓存都在这个地方,可以打开 regedit 自行查看。

1
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts

如果打开你会发现,虽然缓存了,但是缓存的乱七八糟。Name 是一个格式化后的字符串,乍一看是字体名称和样式的组合,然后有一个(TrueType)后缀,仔细一看,这样式千奇百怪,而且还混着 .ttc(TrueType Collection)。很难有一个通用的方式解析这个名称,因此这里我们准确拿到的信息只有字体文件的相对路径。

image-20251020202106916

所以这里我们只好做出取舍,暂时在 Windows 平台不对 Name 进行处理,原封不动(当然还是可以删掉后缀的)地返回注册表中的 Name,以及拼接好的绝对路径,至于字体样式则留空。

不过由于字体名称和样式可以通过加载字体文件读取,所以我们将这一步骤留给了用户。当然 DungineX 可以做到这一点,但是经过测试,这样会导致性能非常差,加载所有字体有明显的卡顿,所以这里还有待进一步优化,其他平台则没有这个问题。


DungineX Trailer

作为 DungineX 的系列文章,还是提一下引擎为好。

🏷️0.1.1的基础上,DungineX 经历了不小的重构,调整了一些文件组织,也更改了一些实现,其中最主要的是增加了对多平台的支持。在特性上,目前正在实现 Application 模块,包括应用的事件处理,以及 GUI。

DungineX with sysfonts

具体集成见 Renderer/Font.cpp

在集成 sysfonts 之后,DungineX 内部会维护字体到文件的映射,从而可以加载任意字体文件。然而,由于刚刚提到的问题,在 Windows 上预先加载字体会有严重的性能问题,所以目前在 Windows 上仅加载 Arial 和 Segoe UI 两个字体,选择会少一些。

Application

Application 是我认为 DungineX 中非常重要的一个部分,也是 DungineX 比较特别的地方。对于游戏引擎来说,GUI 其实并不是核心,很多时候都交给用户来实现简单的 Widget(控件)。但是我个人比较在意这个事情,一个游戏很多时候是需要多个界面的,比如主页、游戏界面、设置界面,甚至还有弹窗之类的,有时候还是挺麻烦的。所以在设计上,顺便就用界面对游戏功能进行了模块化。因此 Application 可以视为对整个游戏流程的封装,具体游戏逻辑可以嵌在一个自定义的界面里。

当然,可以像 Java 的 Swing 一样实现一套 UI,不过对于编译型语言来说,这样似乎不太友好,所以希望能有一套描述语言,从而能够像 HTML 一样动态构建 UI。当然,这套语言不只限于 UI,游戏的各种配置,尤其是资源管理也可以基于其实现。这么一来,就有点 WPF 的味道了。这次的实现还是比较令人满意的,DOM 结构更好,样式也更灵活,也支持了动态属性,比较期待最终的效果。

接下来一章可能会等更久,因为 GUI 比较难以调试,而且涉及更多工程实现。为了保证一定完整性,事件不会单独作为一个章节,会合并在 Application 中介绍。ᓚᘏᗢ