界面上"只是一行文字",引擎里是一条五人流水线。文本渲染是 UI 里最跨学科的一块:排版规则、字体文件格式、Unicode 脚本属性、GPU 图集管理全都沾。Slate 的实现分两层:SlateCore 的 FSlateFontCache 管字形生产,Slate 的布局和绘制层消费字形。底层库是 FreeType(轮廓解析 + 光栅化)和 HarfBuzz(OpenType 整形)。这篇按数据流讲,中文项目的坑集中在两处。
工序一:Itemize(分行分 run)
拿到一串字符后先做脚本识别:按 Unicode 脚本属性(Script)和字体支持度把文本切成 run——每个 run 内脚本一致、字体一致。"HPポーション回復30個" 这种混排会切成四个 run 以上。英文项目几乎永远是一个 run;中文混数字混英文的界面,run 数直接翻倍。
这一步的坑是字体回退:主字体(比如工程指定的美术字体)没覆盖的字符,要按 composite font 的回退链(FCompositeFont 里的 FallbackFonts,默认链路末端是系统字体)找能渲染的字体。找不到就是豆腐块 □。中文项目常见事故:美术字体只做了常用 3500 字,生僻角色名/装备名落到回退链,字重和主字体不一致,玩家一眼看出"这个字发虚"。
工序二:Shape(整形)
FSlateTextShaper 对每个 run 调 HarfBuzz:hb_shape 输入字符序列,输出字形序列——glyph ID、advance(前进宽度)、offset(相对基准点的偏移)。这步解决三件字符到字形不是一一对应的事:
- 合字:fi 两个字符可能合成一个 fi 字形;
- 组合字符:é 可以是 e + ◌́ 两个码点一个字形;
- 复杂脚本:阿拉伯文一个字母在词首词中词尾是不同字形,形状由上下文决定;天城文几十个字母叠成一个字。
中文在这步反而省心:汉字没有上下文形变,advance 恒等于 1em,整形基本等于查表。中文的成本不在这道工序。
工序三与四:换行 + 光栅化
FSlateTextLayout 按 advance 累加做换行(CJK 允许任意字符间断行,英文只在词边界断)。换行结果是一串带绝对位置的字形引用,交给绘制层。
FSlateFontCache::GetGlyph 按 (字体, 字号, glyph ID) 查字形缓存:命中直接返回图集槽位;未命中走 FreeType:加载轮廓(FT_Load_Glyph,字符尺寸越大贝塞尔曲线采样越多)→ 光栅化成灰度位图(FT_Render_Glyph)→ 把位图塞进字体图集(FSlateFontAtlas,满了就开新页)。首次显示一段新文字时的卡顿尖峰,基本都来自这条冷路径——字号越大尖峰越长。
工序五:图集与绘制
字形位图打进图集后,绘制就是普通的图集元素(Slate 批处理那篇讲过,同图集全合并成一个 batch)。所以一段纯文本界面的 draw call 极省,文本的成本全部在 CPU 侧流水线。
两个量化成本
光栅化的轮廓解析成本随字号增长。FreeType 对 quadratic Bézier 轮廓的扫描转换工作量近似和字号像素数的平方成正比:
$$T_{\text{raster}} \propto s^2$$
$s$ 是字号(px)。所以"字号放大一倍的首次卡顿是四倍"不是错觉,是扫描转换的固有性质。缓解办法有二:避免运行时创建大字号动态字体(预烘焙进离线字体资产,UE 的 CacheType = Offline),或接受首个尖峰但用异步字体加载(Slate.EnableAsyncFontLoading 背后的预取)把它移出关键帧。
另一处是 CJK 的图集压力:常用汉字两三千、加上字号梯度(一个界面往往 4~6 个字号),字形位图总量轻松超过单张 2048² 图集,引擎会分页开多张图集。图集跨页意味着文本元素内部出现多纹理,批处理被拆。stat slate 里看到文本 HUD 的 draw call 偏多,多半是字号种类过多导致的图集分页,收敛字号档位比优化任何代码都管用。
工程收尾建议
排查文本性能的顺序:stat slate 定位 → 数字号档位 → 查字体资产的 CacheType(动态改离线)→ 审查 composite font 回退链。文本这块绝大多数"优化"其实是资产治理:管住字号档位和字体覆盖范围,代码层面就没有可优化的了。真正的代码优化点只剩一个——高频重建的 FText(每帧 FText::Format 拼字符串)会把 Shape 缓存全部打碎,稳定文案尽量静态化。