ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

精简中文字符集,解决Unity TMP缺字与生成慢

精简中文字符集,解决Unity TMP缺字与生成慢

做 UI 的应该都有过这种经历:界面写好了,文案也填进去了,一进 Play Mode,TextMeshPro 文本里密密麻麻全是方块和空白。问题不出在文案,而是字体资源里根本没有这些字形。Unity TextMeshPro 的 SDF 渲染方案,必须先把字符放进字形图集(Atlas),运行时才能正常取用,缺字就只能显示成“豆腐块”。中文又不像英文只有 26 个字母,全量塞进去几万个汉字,生成一次能让你等得怀疑人生。所以我在搭建模拟项目 X 的 UI 阶段,直接整理了一套简体中文字符集,专门给开发期用。这套东西说穿了就是一份精心挑选的字符表,配合 TMP 的 Font Asset Creator 生成一个精简字体资源,覆盖率足够日常 UI 文案,生成速度又能控制在可接受范围。这篇就把怎么选字符、怎么生成、怎么补坑完整讲清楚。

1. 先把需求捋清楚:开发期字体为什么这么麻烦

1.1 TMP 缺字是怎么发生的,开发期为什么尤其明显

TextMeshPro 的渲染核心是 SDF,简单理解就是把每个字符的轮廓转成一张带距离场信息的图,运行时在着色器里重建边缘。为了让任意字符都能被显示,字符必须先出现在一张或多张 Atlas 图集里。字体资源 File 决定了图集里预烧录了哪些字符。TextMeshPro 组件在绘制文本时,如果遇到图集中没有的字符,就会显示为空缺字形,常见表现就是 UI 上出现一个个空心方块。

开发期这个问题的频率会成倍放大。原因很简单:开发中的文案是不断变化的。策划今天改活动名,运营明天加版本公告,测试同学随手往表格里填个繁体字,都会把字体资源的缺口暴露出来。我在模拟项目 X 里就遇到过这种情况,某个界面标题正常,副标题出现三个方块,排查半天发现是输入法把简体词转成了繁简混排,字符集里没有对应字形。这种问题如果每次都靠“换一张全量字体图集”来解决,资源体积和生成时间都会失控,尤其是几千上万字的字体资源,每次改动都要重新生成一次,开发节奏完全被打断。

1.2 用表和逻辑告诉你:开发期不该直接上全量字库

很多新手的第一反应是“那就把所有汉字都放进字体资源里”。理论上可行,实际开发中却很不划算。Unicode 的 CJK 统一汉字区段 U+4E00 到 U+9FFF 就有两万多个字符,再加上扩展区更是海量。把这些全部生成到 TMP 图集里,生成时间以小时计都不奇怪,图集页数会膨胀到几十页,编辑器内存和显存占用都跟着暴涨。开发期根本不需要这些生僻字,它们只会拖慢每次迭代。

有这么几种方案可以选择,我常把它们放在一张表里对比:

方案生成时间运行性能缺字风险资源体积适用阶段
全量字符集静态字体极长一般极低很大发布尾期可选
精选常用字符集静态字体适中优按覆盖范围而定小开发期主推
动态字体(Dynamic)无需生成有卡顿风险低,但运行时不可控取决于源字体原型验证可用

“仅用于开发”这个定位,意味着这套字符集不需要追求理论上的无懈可击,它的核心目标是:覆盖绝大多数日常文案,生成够快,图集体积可控,开发流程顺畅。那些极生僻的姓氏、古汉字、异体字,留到正式版本配合最终文案再处理。开发阶段最重要的是速度和稳定,不是“全”。

2. 字符集内容怎么定:覆盖范围与生成思路

2.1 字符范围怎么划:常用字、次常用字与扩展区

做字符集之前要定范围,否则很容易陷入“我到底放多少字”的选择困难。我自己的做法是分三层:第一层是中文常用字,第二层是通用符号和拉丁字符,第三层是项目文案采集的补充字。

常用汉字部分,可以直接使用一套常用字表,大体上就是 3500 个常用字加 1000 个次常用字。这个规模对日常 UI 文案的覆盖率已经很高,游戏里的按钮、弹窗、活动标题基本不会跳出这个范围。如果你在做社交类或内容型产品,可能会有一些动态内容,比如玩家昵称、聊天文本,这就要在字符集之外再做动态字体的兜底方案,而不是一味把字符集加大。

从 Unicode 区间的角度来划分,我建议这样选:

  • 基础拉丁字符:U+0020 到 U+007E,包含半角数字、大小写字母和常见半角标点。
  • CJK 标点:U+3000 到 U+303F,包含中文全角空格“ ”、顿号、句号、书名号等。
  • CJK 统一汉字区:U+4E00 到 U+9FFF,只选取常用字和次常用字,不要全量。
  • 全角形式:U+FF00 到 U+FFEF,包含全角数字、全角拉丁字母和全角标点,按需加入。

很多人容易忽略一个原则:不要为了让字符集“看起来完整”就把所有 Unicode 区间一股脑勾上。扩展区 B 还有几万个字符,字体源文件也不一定包含它们的字形,勾上之后 Missing Glyphs 的数量会非常难看,生成的图集里还会出现一堆空白字形,纯粹浪费资源。

2.2 谁都容易漏的空白和标点

汉字选好了,符号却往往被忽略,而符号恰恰是 UI 文案里出现频率最高的部分。我之前整理字符集时,在空格上吃过亏。TMP 文本里的空格 U+0020 必须存在于字体资源中,否则所有单词或中文词组的间距都会异常。还有换行符 U+000A、回车符 U+000D、Tab 制表符 U+0009,虽然正常 UI 里不常用,但某些多行文本组件中会出现。不换行空格 U+00A0 也建议补上,很多编辑器和翻译软件会自动生成这种字符。

标点符号的中英文区别也很容易出问题。中文文案里的句号、逗号、括号是全角形式,英文是半角形式,两套都要放进去。我列一下开发字符集中比较推荐保留的符号集合:

ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 .,;:!?'"-()[]{}<>/|\`~@#$%^&*_=+ ,。、;:?!“”‘’()【】《》〈〉——……·…·¥%×÷≈≠≤≥℃㎡→←↑↓

这里有三点需要特别提醒。第一,省略号建议放两个 U+2026 字符,而不是几个英文句点代替,否则字体渲染出来只是三个点,不是标准的居中省略号。第二,破折号在中文排版中一般是两字位宽度,字符集里存在 U+2014 EM DASH 即可,文案输入时连写两个。第三,中文单双引号“和”、‘和’,左右方向不同,不要只放一边,否则文案一侧是直引号一侧是弯引号,看起来非常别扭。

2.3 从项目文案里自动提取字符集的脚本

直接靠常用字表虽然稳妥,但可能会包含大量实际用不到的汉字,让图集多出不少页数。更聪明的做法,是把当前项目的有效文案扫描一遍,提取去重后得到一份“最小字符集”,再用常用字表做兜底。这样生成的字体资源最贴合实际,生成时间也最短。

提供一个参考脚本,语言用 Python,跑一遍就能把多个文本文件里的字符收集起来:

from pathlib import Path paths = [ "UI/Strings/zh-CN.txt", "UI/Strings/item_names.txt", "UI/Strings/npc_names.txt", ] chars = set() for p in paths: path = Path(p) if not path.exists(): continue text = path.read_text(encoding="utf-8") for ch in text: if ch not in "\r\n\t": chars.add(ch) # 空白字符不能省 chars.update([" ", "\n", "\t"]) output = "".join(sorted(chars)) with open("font_charset_dev.txt", "w", encoding="utf-8", newline="\n") as f: f.write(output) print("charset chars:", len(chars))

这段脚本会把所有文本中出现过的字符做成合集,再去重排序,最后生成一个精简的 TXT 字符集文件。跑完后会把文件放在项目外,导入 Unity 项目目录里使用。脚本的编码输出是 UTF-8 无 BOM,这一点对后面的 TMP 生成很重要,下面会解释原因。

3. 动手制作:从字符集文件到 TMP Font Asset

3.1 准备两样东西:字体源文件和 UTF-8 字符集文件

开始制作之前,先要准备好两个文件。第一个是字体源文件,我用的是一份开源的黑体类 TTF/OTF,支持简体中文。选择源文件时注意两点:一是尽量用 TTF 或 OTF,不要用 TTC 合并字体包,TMP 对 TTC 的支持不稳定,容易出现导入后识别不到字形的情况;二是确认字体文件的使用授权,开发期团队内使用通常问题不大,但如果项目要对外发布,字体版权必须提前落实。

第二个就是上一节生成的字符集文件。这个文件看起来就是个纯文本,内容是一行行字符,没有特殊格式。唯一要注意的是编码必须是 UTF-8,最好是无 BOM。TMP 读取字符文件时如果碰到带 BOM 的文件,可能把开头的不可见字符也当成一个字形塞进去,导致生成的字体资源里莫名其妙多出一个空白字符,或者第一个字符显示异常。

还需要把字符集文件放进 Unity 项目内的某个目录,比如放在Assets/Fonts/CharacterSets/下。TMP 的 Font Asset Creator 并不要求文件在 Assets 内,但放在 Assets 里可以纳入版本管理,团队成员拉下来直接能用,不用各自再找文件。

3.2 Font Asset Creator 参数填法:一套开发期配置单

Unity 窗口菜单里找到Window > TextMeshPro > Font Asset Creator,打开后就是生成字体资源的界面。核心操作是把 Source Font File 指向字体文件,把 Character Set 单选区切到Characters from File,再指定字符集文件,然后点 Generate Font Atlas。

真正容易踩坑的是下面这一组参数,我用开发期的默认推荐值说明:

参数开发建议值理由
Sampling Point Size80 到 96点阵采样大小,值越大字形细节越丰富,但图集占用越大
Padding8 到 12SDF 边缘重建需要梯度空间,太小会锯齿发虚,太大浪费图集
Packing MethodFast开发期优先速度,Optimum 虽然排列紧凑但生成更慢
Atlas Resolution2048 x 2048单页容纳字数和显存占用之间较平衡
Character SetCharacters from File精确控制字库内容
Render ModeSDFAA观感清晰抗锯齿,正式包再按平台换 SDF8 或 MSDF
Include Font Data建议开启避免其他机器或打包后源字体路径失效导致样式错乱

填好参数后,直接点 Generate Font Atlas。生成过程中窗口下方会显示进度条和统计信息,重点看 Missing Glyphs 的数量。这个数值表示“字符集里有,但字体源文件里没有对应字形”的字符数。如果 Missing Glyphs 为 0,说明源字体完整覆盖了你的字符集,可以放心使用。如果大于 0,需要把缺的字从字符集里去掉,或者换一个字形覆盖更全的字体源文件。

生成完成后,点Save as按钮,把资源保存成 TMP Font Asset。保存完,在任意 TextMeshProUGUI 组件的 Font Asset 字段上指向刚生成的文件,界面里的中文就能正常显示了。

3.3 一页图集能塞多少字,为什么说字不能贪多

为了方便你预估资源规模和生成时间,这里给一个简单的容量估算。假如 Sampling Point Size 是 90,Padding 是 9,每个汉字在 Atlas 中大约占一个 (90+9×2) 也就是 108 像素的格子。2048×2048 分辨率的图集,单页最多放进约 19 乘 19 个字,也就是 361 个字形。这样算下来,收集 5000 个字符就要十几页图集,生成自然快不了。

这解释了为什么我经常提醒开发同学不要在一个字符集里狂塞字符。表面上多放几百个字不是大事,实际生成速度和图集页数都会线性上涨,最后编辑器还会因为 Atlas 页数太多出现明显的加载延迟。精简字符集的收益立竿见影:用最贴近项目的字符集,配合常用字兜底,生成时间通常可以从几分钟压到一分钟内。

4. 把流程放到模拟项目 X 里走一遍

4.1 从 6000 字砍到 2400 字,生成时间的变化

我在模拟项目 X 搭建的时候,UI 规模大概几十个界面,文案来自配置表和预制体,直接扫出来的有效字符数并不大。第一次图省事,我直接拿一份现成的“常用 3500 + 次常用 1000 + 符号 800”字符集生成字体资源,总字符接近 6000。用 2048×2048、SDFAA、Padding 9 的配置跑,一次生成要等大概五分钟,实在熬人。

后来改成先跑采集脚本,从项目所有文案文件里提取去重,再把常用字表合并进去,最终字符集缩减到 2400 出头。同样配置再生成一次,时间降到大概一分半,图集页数也少了接近一半。这个对比给我留下的印象很深:字符集的“精”比“全”更能提升开发体验。之后每次项目大版本更新,我都会重新跑一遍采集脚本,把新增角色名、活动名、公告文本里的新字符补进去,缺失的字及时加进字符集再生成一次,整套流程基本能在几分钟内完成。

采集脚本之外,我还写了一个简单的 C# 编辑器工具,用来扫描场景和预制体里所有 TMP 组件的文本,把每个组件当前显示的字符自动收集汇总。这个工具的开发思路很简单:

using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; using TMPro; public static class TmpCharSetTool { [MenuItem("Tools/TMP/Collect TMP Texts")] public static void Collect() { HashSet<char> set = new HashSet<char>(); string[] guids = AssetDatabase.FindAssets("t:Prefab"); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); if (prefab == null) continue; TMP_Text[] texts = prefab.GetComponentsInChildren<TMP_Text>(true); foreach (TMP_Text tmp in texts) { foreach (char c in tmp.text) { set.Add(c); } } } StringBuilder sb = new StringBuilder(); foreach (char c in set) sb.Append(c); File.WriteAllText("Assets/Fonts/CharacterSets/tmp_charset.txt", sb.ToString(), new UTF8Encoding(false)); AssetDatabase.Refresh(); Debug.Log("charset count: " + set.Count); } }

这个工具不会替换你的人工符号补充,但它能快速汇总界面中出现过的所有字符,配合常用字表兜底,比纯手工维护字符集省力得多。

4.2 录制现场踩到的三个坑

第一个坑是 UTF-8 BOM。最初的字符集文件是从文本编辑器里直接保存的,保存时默认带了 BOM。生成字体资源后发现每次使用都会在文本开头多出一个零宽字符,查了很久才发现是文件编码的问题。解决办法很简单:另存为 UTF-8 无 BOM,或者在上面的 Python 脚本里用encoding="utf-8"而不是utf-8-sig。

第二个坑是空格被脚本当作多余字符过滤掉。采集脚本如果有strip()或replace(" ", "")之类的操作,空格就直接没了。去掉后所有 TMP 文本的单词间距、中文文案的自然间隔全部挤在一起,看起来像串行文本。排查半天才意识到字符集里没有空格。修正之后在字符集合里显式加上空格、Tab、换行三个字符,问题立刻消失。

第三个坑是遇到繁体字 Missing Glyphs。项目文本里混了几个繁体字,字符集里有,但字体源文件本身没有对应字形,所以生成结果依然缺字。解决办法不是换一套更大的字符集,而是给主字体资源挂一个 Fallback 后备字体资源,或者在字体源文件层面换用字形覆盖更广的黑体。开发期直接用 Fallback 最省事,主字体不用重新生成,一个备用字体资源专门承载少量冷门字。

4.3 开发/发布字体资源分开,一键切换

开发期的字体资源和正式发布字体资源,我从来不会混用。开发字体用精简字符集,正式字体用最终文案集的完整字体资源。为了切换方便,我维护了两个 TMP Font Asset,然后在项目里通过一个 Editor 菜单项批量替换所有预制体上的 TMP 组件字体资源。菜单按钮遍历项目里的 Prefab 和场景,把所有 TMP 组件的 Font Asset 统一改成开发版或正式版。这样开发分支和发布分支的预制体无需修改资源引用,只是打包前切一次字体即可。

需要注意一点:如果预制体是嵌套的 SubPrefab,替换脚本要递归处理所有子物体。我一开始只替换了 Prefab 根节点的 TMP 组件,导致部分界面的字体还是旧的,看起来像是没切成功。后来统一改成GetComponentsInChildren<TMP_Text>(true),才算彻底解决。

5. 常见问题排查与避坑清单

5.1 缺字的排查路径和 Fallback 方案

UI 上看到方块时,第一步不是改代码,而是先确认这个字符到底来自哪里。把有问题的文本复制到文本编辑器里,查看字符的 Unicode 码值,再对照字符集文件确认它是否在集合内。如果在集合内但界面上仍然缺字,那问题在字体源文件本身,源文件就没有这个字形,TMP 自然生成不出来。

这种场景最有效的处理方式不是无限扩充主字体,而是给字体资源挂 Fallback。在 TMP Font Asset 的 Inspector 底部找到 Fallback Font Assets 列表,把另一个包含生僻字的字体资源加进去。渲染时 TMP 会在主字体图集里找不到字形后,自动去后备字体里匹配。开发期我给主字体挂了两个后备字体资源,一个负责生僻汉字,一个负责常见文化符号,生成主字体资源的时间几乎没有额外增加。

5.2 生成慢、图集大的治理顺序

生成慢和资源大往往一起出现,治理顺序我建议遵循“剔除 → 降采样 → 降分辨率 → 换打包模式”的路线。先看字符集是否有大量用不到的字符,把明显冗余的生僻姓氏、异体字、不全的繁简体混合内容剔除。再降低 Sampling Point Size,从 96 降到 80 往往能明显减少图集占用。之后再看 Atlas Resolution,如果图集页数很多,说明单页容量不够,可以适当提升分辨率或打开 Multi Atlas Textures。最后才考虑把 Render Mode 从 SDFAA 换成 SDF8,因为这会牺牲一部分显示质量,开发期不建议优先动这一项。

这里有个容易误解的点:Atlas Resolution 提高到 4096 并不一定让整体资源变小。单页能放更多字确实会减少页数,但 4096 图集本身占用的显存远超 2048,在开发机上可能看不出差别,真机上却可能成为负担。开发期的取舍标准只有一个:生成时间别拖太久,界面显示别缺字也别发虚。

5.3 动态字体和静态字体的正确打开方式

TMP 支持把字体资源设置成动态模式,运行时遇到字符集之外的字会自动计算 SDF 并加入图集,看似很省事。但实践中我很少在开发期把它当主力方案,原因有三个:第一,动态生成是运行时 CPU/GPU 成本,界面第一次出现新字符时会明显卡一下,复杂 UI 上这个问题会被放大许多倍;第二,动态字体依赖源字体文件,一旦同事机器上没有对应字体,或者项目被拷到别的电脑上运行,表现可能就是大面积缺字;第三,动态字体的图集在运行时按需增长,内存里可控性很差,低端设备很容易出现纹理占用飙升。

真正适合动态字体的,是那些文本内容完全不可控的场景,比如玩家输入框、聊天系统、网页内容显示。开发期的 UI 文案是可控的,用静态字体配合精简字符集要稳定得多。

5.4 打包前最后的检查清单

从开发期切到正式发布前,我会按这个清单快速过一遍:

检查项错误表现正确做法
Include Font Data 是否开启打包后真机字体错乱或缺失字体资源勾选 Include Font Data
是否误用开发版精简字体发布版缺少正式文案中的生僻字切换为正式字体资源或补全字符
Fallback 字体是否随包切换界面后某些字符空白确认后备字体资源被主字体引用
字体源文件授权是否落实法律和合规风险确认字体可商用或符合项目授权
图集页数是否过多真机显存压力大精简字符,或调整图集分辨率

这套流程走完,发布包的字体问题基本就能提前拦住。开发期那份简体中文字符集,则安安静静待在项目里,继续服务下一轮迭代。

最后分享一个小习惯

我用这个方案替换掉最初的动态字体方案后,字体出问题的频率明显下降。现在我的做法是把font_charset_dev.txt文件放在字体资源同一级目录下,每次遇到缺字,先把字补进文件,再重新生成一次字体资源,最后连同字符集文件一起提交版本管理。这样整个团队共用同一份字符集,缺字不再只出现在某一个人界面上,而是变成一次性的、可追溯的修改。如果你也被 TMP 缺字和生成时间折磨,不妨从整理一份精简字符集开始,先把开发期体验提上来,正式包的资源优化再单独处理,这个投入回报很稳定。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进