Unity字体裁剪性能优化方案:从动态字体到静态图集
如果你接手过一个中大型Unity项目大概率遇到过这样的场景界面功能都做完了真机一发版聊天框一打开就顿一下Profiler里跳出的是字体纹理重建。这通常不是你的业务代码有问题而是字体裁剪没做好。Unity里的字体裁剪说穿了就是把运行时动态生成的字体图集换成一份提前算好的静态字库白名单让引擎在显示文字时不再临时栅格化字形内存、CPU和Canvas重建都跟着降下来。这件事看着简单做起来有不少细节。字符集从哪来、怎么避免漏字、中文和泰文这类大字符集怎么处理、用户输入的文本怎么兜底每一步都藏坑。我在某手机端项目里前后折腾了两个迭代才把这套流程理顺。这篇文章把完整思路和实操步骤都写出来给你做参考。1. 字体贴图吃掉的内存账本动态字体的真实开销1.1 字体图集为什么这么大TTF字体文件本身是矢量轮廓CPU在渲染文字时需要把轮廓栅格化成像素栅格化的结果会被缓存到一张或多张位图纹理里这张纹理就是常说的字体图集Font Atlas。Unity的UI系统在渲染文字时每次绘制都是把图集里的字形区域当成贴图使用。图集大小直接由“字形个数”和“单个字形分辨率”决定。拿中文举例常用汉字三千多个加上标点符号和字母数字往少了算也要三千左右字形。每个汉字如果按32×32像素的密度放进图集单个字形占1024个像素三千字就是约300万像素一张2048×2048的纹理勉强装得下。如果按更好的清晰度需求放到64×64就需要一张4096×4096的纹理。只看纹理尺寸不太直观换成人话一张4096×4096的RGBA32纹理显存占用是4096×4096×4字节等于64MB。如果字体纹理维护了多页翻倍甚至更多是常事。所以很多项目的字体纹理一优化完显存直接从“顶不住”变成“很宽裕”。1.2 动态字体的隐藏成本Unity旧版UIUGUI的Text组件默认用的是动态字体。所谓动态就是指运行时哪些字被用到引擎就在字体纹理里现生成对应的字形。第一次显示“加载中”三个字纹理里没有于是调字体引擎栅格化这三个字再把新生成的区域上传给GPU。这个流程听起来不慢但在渲染压力大的帧里一次字体纹理重建会打乱Canvas的合批让后续的UI网格全部重算。更麻烦的是动态字体维护的是一个有上限的字符缓存。字符塞满了就会把一些旧字形替换出去。下次再显示那个被替换的汉字又得重新生成于是反复抖动。我见过一个偏社交类的项目聊天房间里玩家的昵称包含各种生僻字把动态字体缓存直接打穿滑动聊天列表时就一卡一卡的。Profiler里看到的函数名很扎眼基本都是字体纹理相关的一连串调用。所以把字体从“运行时动态生成”改成“预先裁剪好的静态图集”省的不只是内存还有CPU生成字形、纹理上传、Canvas rebatch这一整套连锁开销。1.3 先算笔账再动手做裁剪之前建议先量化一下当前项目的字体开销。在Profiler里截一段内存快照按大小排序看看所有包含Font Asset、Dynamic Font同名的资源占了多大。我常用的统计方式是打开Profiler Memory选中所有字体纹理相关资源记录总大小在开发包里统计场景里正在使用的动态字体数量用一张中等规模的静态字体资产替换后再对比同样内存快照。拿我之前处理的一个项目举例优化前动态字体占用了约96MB换成裁剪后的静态图集只有4MB差了24倍。你没看错这就是字体裁剪能带来的收益量级。在低端机上这24倍往往就是“能跑”和“流畅”的差距。2. 静态化改造让字体字符集变成“白名单”2.1 旧UGUI项目的字符预取方案如果你的项目还在用UGUI内置Text最直接的做法是关掉运行时动态生成改成静态字体并在启动阶段把字符集一次性“喂”给字体。Unity提供了RequestCharactersInTexture这个接口调用后引擎会把传入字符串里的所有字符预先栅格化到字体纹理上。示例写法如下public static class FontPrewarmer { // 把业务里可能出现的字符都拼进这个字符串 private const string CommonChars 0123456789 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 。、“”‘’—…·【】《》; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void PrewarmDefaultFont() { Font defaultFont Resources.GetBuiltinResourceFont(Arial.ttf); if (defaultFont ! null defaultFont.dynamic) { defaultFont.RequestCharactersInTexture(CommonChars); defaultFont.textureRebuildCallback OnFontTextureRebuilt; } } private static void OnFontTextureRebuilt() { // 纹理重建后可以在这里做日志记录避免高频触发 } }这段代码的关键是全局初始化一次把字符集写进字体纹理。之后UI运行时基本不会再触发重建。缺点也很明显RequestCharactersInTexture只解决“已知字符”如果后来有新的界面用了白名单以外的字符依然会重建。所以旧UGUI项目更适合配合“字符普查”一起用普查字符集导出成这个字符串。2.2 TextMesh Pro的静态字体资产玩法新项目或者已经切到TextMesh ProTMP的项目直接在Font Asset上做静态化是最省心的。TMP的字体资产创建器Font Asset Creator可以指定字符来源选择“Characters from File”从文本文件里导入字符集然后一次性生成图集。具体步骤是准备一个UTF-8编码的txt文件里面放所有需要支持的文字、数字和标点不含换行以外的空白打开Window TextMeshPro Font Asset CreatorSource Font File选择你的TTF字体文件Sampling Point Size按项目显示密度来通常32到48之间越大越清晰但图集也越容易满Atlas Resolution设为2048×2048或4096×4096取决于字符数量Character Set选Characters from File再点Import Characters选择txt文件Render Mode一般选SDFAA移动端可以用SDF8或SDF16省纹理带宽点Generate Font Atlas完成后Save as Asset保存。保存出来的Font Asset就是静态的。这个资产的atlasPopulationMode默认是StaticTMP在显示文字时只会查表不会动态往图集里塞字形。UI上所有TextMeshProUGUI组件把它们的Font Asset都替换成这个新资产即可。2.3 为什么强烈建议“宁可缺字也不动态重建”有些同事会在静态字体资产的基础上把某些Text组件改回Dynamic模式觉得这样灵活缺什么字自动补。我的建议是千万别这么干。一旦某个主要字体资产变成Dynamic它在运行时会不断膨胀几轮页面切下来图集就满了性能问题和内存问题重新出现之前做的裁剪全部白费。正确的思路是正式UI用的字体资产保持静态再把一个体积很小的Dynamic字体资产作为兜底通过Fallback链串起来。这样主字体覆盖常用字符未收录的罕见字才会进入兜底字体的小图集。兜底字体因为用得少图形集不会撑爆。TMP的Fallback设置很简单选中主字体Asset在Inspector下方有Fallback Font Asset List把那个兜底字体资产拖进去。运行时会先查主字体没有的字符沿Fallback顺序查下去。这样既保住了静态裁剪的收益也避免了明显的缺字。3. 字符集从哪来给你的项目文本做一次“普查”3.1 收集唯一字符的三种来源字符集白名单不是靠手敲出来的除非你的项目只有十几个字。真实项目要收集的字符来源至少有三种。第一种是场景和预制体里直接写死的文本。遍历Assets目录下所有.prefab和.unity文件提取Text组件或TMP组件里text属性的值这个过程可以用Editor脚本自动化。需要注意TMP组件在序列化文件里字段名是m_textUGUI是m_Text取值时要区分。第二种是代码里的字符串字面量。比如提示弹窗“确认删除吗”、按钮文案“立即领取”、各种拼接出来的标题。字符串字面量散落在.cs文件里用正则...扫一遍就能捞出大部分。这部分容易漏因为很多UI文案是运行时动态拼出来的。第三种是多语言表或配置表。本地化CSV、Excel导出的JSON、远程配置的文本都属于运行时会出现的字符来源。不要小看这一步有些语言包文件里的文案在界面上根本不会显示全但为了保险还是得收录。把这些来源的字符全部丢进一个HashSetchar去重得到的就是这个项目的“字符全集”。我提供一个简洁的收集脚本思路public static class CharacterCollector { public static HashSetchar CollectFromAsset() { HashSetchar result new HashSetchar(); string[] prefabGuids UnityEditor.AssetDatabase.FindAssets(t:Prefab); foreach (string guid in prefabGuids) { string path UnityEditor.AssetDatabase.GUIDToAssetPath(guid); string content File.ReadAllText(path); // 用正则取出 m_text 字段的字符串值简化示意 foreach (Match match in Regex.Matches(content, \m_?[Tt]ext\:\\s*\([^\]*)\)) { string text match.Groups[1].Value; foreach (char c in text) result.Add(c); } } // 再遍历所有 .cs 文件里的字符串字面量 // 再遍历多语言表文件 return result; } public static void ExportToTxt(HashSetchar chars, string outputPath) { Listchar list new Listchar(chars); list.Sort(); File.WriteAllText(outputPath, new string(list.ToArray()), Encoding.UTF8); } }这个脚本写得比较粗糙线上项目我会加过滤逻辑扔掉换行符、制表符和编辑器用的特殊标记。产出txt之后直接喂给TMP的Font Asset Creator。3.2 按语言和模块拆分字符集把所有字符合并成一份白名单是“能用”的底线但未必是“好用”的方案。中文项目里标题字体、正文字体、数字字体往往是不同的字体资产。如果全部合并一个字体资产要包含所有字形生成出来的图集尺寸会很大。我的建议是至少分成两级一级字体覆盖界面高频字符数字、英文、常用标点、以及UI组件里反复出现的那几百个汉字图集可以控制在1024×1024以内二级字体覆盖剩余的常用汉字和扩展字符比如一级字符集之外的GB2312一级汉字图集用2048或4096。为什么分两级因为TMP渲染时同一个Text组件如果混用了不同字体资产会产生额外的draw call。但只要把二级字体挂到一级字体的Fallback里显示顺序是自动查找的两个字体资产都会参与渲染只是字体资产本身小了很多。加载和切换成本也更低。3.3 容易被漏掉的特殊字符清单做字符普查时我踩过几个特别隐蔽的坑列出来给你避雷。全角空格\u3000和不间断空格\u00A0这两种字符看起来像普通空格但实际是有独立Unicode码位的。TMP如果不收录它们显示出来会变成方框或者宽度异常导致文案对不齐。很多文本对齐问题查到最后都是这里漏了。中文省略号和破折号中文省略号是“……”三个点一组破折号是“——”都有单独的码位且宽度和英文连字符不同。字符普查脚本如果只看可见字符很容易把它们过滤掉。特殊符号手机号里的、价格里的、时间里的:、版本号里的.这些符号确实简单但字符集导出后要不要检查一遍往往就漏个~或#。服务器下发的富文本标记有些UI用的是TMP的富文本比如color#FF0000这里面的尖括号、#、等号都必须在字符集里否则整个标签会显示成原文。建议每轮字符集导出后额外加一组固定字符模板把数字、大小写英文、常见半角符号、常见全角标点、业务专用符号全量塞进去。宁可多收一百个字符也不要漏一个。4. 多语言与动态文本边界怎么兜住4.1 多语言字体资产怎么拆如果项目只是单一中文上面那套已经够用。但很多项目要做繁体中文、日文、韩文、泰文、西里尔字母甚至阿拉伯文。把这些全部塞进一张图集4096×4096都不够用而且不同语言的字符在同一个图集里会互相挤占位置生成时容易出现重叠或模糊。更合理的做法是按语言拆分字体资产。比如中文、日文、韩文各一套Font Asset英文和数字作为共享字符集放进每套语言的前置部分。切换界面语言时按当前语言加载对应的字体资产并替换到所用TMP组件的引用上。这里有一个实践上的细节直接修改所有TextMeshProUGUI的Font引用是笨办法。我一般会做一个全局的字体表用一个静态类记录当前语言对应的主字体和Fallback字体链。语言切换时批量遍历当前激活界面里的TMP组件统一替换字体属性。TMP_Text的font属性赋值后会立刻重建网格所以切换时要注意避免在遍历过程中反复触发网格更新先收集组件再统一赋值。4.2 Fallback零缺字的关键最后一环前面提过Fallback机制这里展开说。TMP的Fallback可以无限链下去主字体查不到字符时会逐个查Fallback字体。如果Fallback链里最后一个是Dynamic字体那所有主字体没收录的字符都能显示不会缺字。我在项目里的配置是正式UI全部用静态主字体Fallback 1是语言对应的静态扩展字体收录生僻字、全字符集Fallback 2是一个极小的Dynamic字体只作为最后兜底图集上限设为1024×1024所有TMP组件统一使用同一套字体资产。这样主字体的内存是可控的漏网字符也会落到专门的动态兜底里。动态字体在最坏情况下撑爆1024图集也只增加1MB左右内存明显比一整张4096动态图集健康得多。4.3 玩家输入、昵称、聊天文本怎么处理任何界面上的静态文案都逃不出字符普查但玩家输入是真正的“不可控字符”。昵称、聊天消息、邮件内容、自定义签名这些文本里的字符你不可能提前预知。对此我的策略是分层治理第一优先限制输入字符集。注册昵称、签名这类输入框通过TextMeshProUGUI.text设置前后端同时校验只允许输入白名单内的字符。这个规则要在策划表里写清楚否则用户输入生僻字后在UI上变成方框那可不是字体问题而是产品体验问题。第二优先运行时尝试补充字符。TMP动态字体资产支持TryAddCharacters接口可以主动把一段文本里缺失的字符塞进图集。比如玩家输入了“龘”聊天界面拿到文本后可以先调用TryAddCharacters(龘)if (dynamicFallbackFontAsset.TryAddCharacters(龘, out bool addedSuccessfully)) { // 添加成功重新刷新当前TMP组件即可 tmp.ForceMeshUpdate(); }这个函数只对Dynamic字体资产有效静态字体调用会直接返回false所以它天然适合放在Fallback链末端的动态字体上。第三优先接受极低概率的缺字兜底。如果聊天文本里出现了生僻到连兜底图集都塞不下的字那只能显示为一个方框。此时运维和客服的反馈流程要能接住这类问题扩充字符集重新生成兜底字体而不是让用户在真机上干等。还有一类文本特别容易漏就是运营活动动态下发的文案。这些文案来自后台配置可能包含各种花哨符号甚至emoji。由于不是所有人都会看到同一个活动字符普查把把活动文案装进去也不现实。我一般会要求运营配置工具在发布时自动扫描一次文案字符串如果有新增字符就追加进字符集并重新生成字体资产如果临时不能做至少保证兜底动态字体存在。5. 裁剪之后的验证、监控和流程防反弹5.1 缺字的二分定位法字体裁剪上线后最怕的不是内存不够而是某个界面上突然冒出一个方框。排查这种问题要有一个标准流程我总结为“二分定位”第一步打开开发包进入出问题的界面把有问题的那个文本原样复制出来确认是什么字符。第二步打开字体资产的Character Table用编辑器搜索这个字符看它是否在列表中。如果在问题出在渲染或材质如果不在问题出在字符集缺失。第三步顺着Fallback链逐条查。如果主字体没有但Fallback有检查Fallback字体是否成功加载、链的顺序是否错误、是否因为材质key冲突导致Fallback没有生效。第四步查文本里是否有不可见控制字符。某些换行符、零宽空格不会在编辑器里显示但会真实影响排版这种不一定是字体缺失。定位到具体原因后修起来就很快缺字符就补字符集重新生成字体资产材质问题就检查该TMP组件引用的Material是否被其他组件意外覆盖。5.2 在CI里加一道字符校验关卡人工检查漏字是很折磨人的。因为我们项目里文案量大版本迭代频繁我后来做了一个校验脚本挂在CI上所有新增素材入库前自动跑一遍。脚本做的事很简单遍历本次修改涉及的Prefab、场景、代码文本和本地化表收集所有字符再遍历所有使用静态字体资产的TMP组件收集字体的字符表两组字符做差集如果有差集输出缺字的组件名称和缺失字符列表CI直接标红。核心逻辑类似这样public static bool ValidateFontCoverage(TMP_FontAsset fontAsset, IEnumerablestring texts) { HashSetchar missing new HashSetchar(); foreach (string text in texts) { foreach (char c in text) { if (!fontAsset.characterTable.Contains(c)) missing.Add(c); } } return missing.Count 0; }这个校验不依赖运行时纯编辑器就能跑耗时很短。把它挂在CI的编译后步骤里每次合入代码自动执行一次。有了这层保障漏字基本不会活到提测阶段。5.3 流程约定与持续维护字体裁剪不是一次性工作它必须变成项目里的一个常规流程。我整理了一套团队约定你可以参考着用所有UI文案必须进多语言表或配置表不允许在Prefab里写死。这句话可能有些绝对但这是保证字符普查能扫全的前提。写死的文本在Prefab里也能被扫描到但代码动态拼接的还是要靠规范。新增UI界面时要跑一次字符普查脚本把新增字符添加到白名单txt文件并重新生成Font Asset。生成新Font Asset后要检查Atlas Resolution和Sampling Point Size是否还匹配当前字符数量。字符增加会导致图集变挤字号小或者模糊时优先放大图集而不是降低采样。字体资产文件、字符集txt统一提交不要放在个人分支的本地目录否则团队其他人没法复现同样的资产。这些约定看起来繁琐但真正帮大家省掉的是大量的“为什么这个界面缺字”排查时间。文字显示是桌面级基础功能出bug的影响面比一个技能逻辑出bug大得多。我在实际项目里的体会是字体裁剪方案好不好一半靠技术实现另一半靠团队流程能坚持多久。只要开发规范里能保证字符集不断更新静态字体方案就能一直稳定运行。后续如果想更进一步还可以把字体资产放进Addressable按需加载语言包切换时只加载对应字体内存还能继续降那就是另一个故事的展开了。