Unity运行时加载系统字体:动态TMP字体资产,包体直降几十兆
简介UnityNativeOSFont是一款面向Unity开发者的开源插件用于在运行时获取操作系统本地字体并转换为TextMeshPro可用的动态字体资源突破Unity默认字体管理的局限特别适合电子书籍、教育软件及多语言本地化等需要高度定制文本效果的项目。压缩包内共107个文件其中ShaderLab相关文件占比突出含13个Shader、4个cginc着色器库和2个C#脚本以及23个Asset预设、示例场景、TMP Settings与PDF文档整体体积仅1.33MB。已有1042人学习下载开发者可通过C#接口动态加载系统字体借助自定义Shader实现抗锯齿、描边、阴影等视觉效果也可构建字体选择下拉菜单供用户切换免去预置字体的体积负担。资源还提供了跨平台兼容性处理与移动端性能优化参考适合具备一定Unity基础的中级开发者快速集成。 把一个中文包体压到百兆以下时字体往往是最憋屈的一笔账。动画、贴图、音频都能靠压缩和合批抠出空间但中文字体文件本身就摆在那里完整版的思源黑体一个字重接近10MB做三四个字重再加日韩泰文字包体直接多出几十兆。今天想聊的方案是在运行时读取操作系统字体通过 Unity 的原生字体接口加载再转成一个动态的 TextMeshPro 字体资产。这套流程跑通之后内置字体包可以从几十兆降到接近零UI 的文字观感也会和系统原生界面保持一致。这篇文章不是讲在编辑器里静态创建 TMP Font Asset 的常规流程而是围绕“运行时动态获取”这条主线把跨平台字体命名差异、动态图集内存、布局刷新、缺字兜底这些坑一次说清楚。适合正在做包体优化、多语言适配或者希望让游戏 UI 更贴近系统原生的 Unity 开发者参考。1. 从包体负担谈起系统字体方案解决的是哪类问题1.1 字体的重量不是一张图能打发的很多团队初期不重视字体体积是因为在编辑器里看单个字体文件并没有多扎眼。真正开始做多语言适配时问题才会暴露出来。中文简体、中文繁体、日文、韩文、泰文、阿拉伯文每一种语言都需要一套字形而 TMP 的静态 FontAsset 在字符集选“全量”的时候中文就要生成多张图集页。每张 2048×2048 的 SDF 图集显存占用大约 16MB这还没算字体文件本身在包体和内存里的开销。我见过一个做法很常见的项目简体中文用思源黑体 Regular 和 Bold 两个字重日文再用一套 Noto Sans JP还没加入韩文就已经多出 40 多兆包体。如果这时候甲方又提一句“泰文和阿拉伯文后面也要加”整个资源管线就得重新排。1.2 系统字体方案算的是另外三笔账第一笔账是包体。采用系统字体后项目基本不再需要内置主流语言字体包那些 10 到 20MB 级别的文件都可以从资源管线里移除。第二笔账是观感。操作系统的原生界面字体通常由平台设计师深度调校过比如 iOS 的苹方、Android 的思源黑体变体游戏 UI 如果直接使用这些字体会天然带有系统级的视觉协调感不会出现“第三方字体和系统控件放一起显得突兀”的问题。第三笔账是覆盖范围。用户设备上装了什么语言库系统字体方案就能覆盖什么语言尤其是那些冷门小语种内置字体包很难完整覆盖但系统字库的覆盖率往往远超预期。这个方案当然也有边界。如果项目的核心 UI 强调品牌感比如标题栏需要某个手写字体或艺术字体那仍然需要内置少量静态字体资产用于品牌场景。系统字体方案是解决“通用文本”的大盘而不是取代所有字体设计。2. 获取系统字体名称跨平台接口与命名差异2.1 Font.GetOSInstalledFontNames 拿到的到底是谁Unity 原生提供了一个很直接的接口Font.GetOSInstalledFontNames()它返回当前操作系统可用的字体家族名称数组。用下面的代码就能把所有字体名打到控制台string[] installedFontNames Font.GetOSInstalledFontNames(); Debug.Log($System font count: {installedFontNames.Length}); for (int i 0; i installedFontNames.Length; i) { Debug.Log($[{i}] {installedFontNames[i]}); }在 Windows 编辑器里你会看到 “Arial”“Microsoft YaHei”“SimHei” 这类名称macOS 上会出现 “PingFang SC”“Hiragino Sans GB”iOS 真机同样能拿到苹方相关的名字。Android 的情况稍微特别一点Unity 在 Android 上返回的更多是逻辑字体名比如 “sans-serif”“sans-serif-light”“monospace”部分厂商 ROM 也会返回类似 “Noto Sans CJK SC” 的具体家族名。这里有一个非常容易踩的坑这个 API 是同步的第一次调用时 Unity 需要枚举系统字体库低端 Android 机器上可能消耗几十到几百毫秒。所以不要把它放在 Update 或任何热路径里建议只在启动初始化阶段调用一次并把结果缓存下来。2.2 不同平台命名不同匹配策略必须写成数组既然每个平台的字体名称都不一样代码里就必须维护一份候选名列表按优先级逐个匹配。不同语言的候选字体整理如下使用场景优先候选字体名按顺序简体中文PingFang SC / Microsoft YaHei / Noto Sans CJK SC / sans-serif繁体中文PingFang TC / Microsoft JhengHei / Noto Sans CJK TC日文Hiragino Sans / Yu Gothic / Noto Sans CJK JP韩文Apple SD Gothic Neo / Malgun Gothic / Noto Sans CJK KR泰文Thonburi / Leelawadee UI阿拉伯文Geeza Pro / Segoe UI匹配的时候不要用严格相等判断因为厂商 ROM 可能改名字比如 “Noto Sans CJK SC” 在部分设备上可能带版本后缀或地区后缀。建议用大小写不敏感的Contains判断using System; public static string PickSystemFontName(string[] installedNames, string[] candidates) { for (int i 0; i candidates.Length; i) { for (int j 0; j installedNames.Length; j) { if (installedNames[j].IndexOf(candidates[i], StringComparison.OrdinalIgnoreCase) 0) { return installedNames[j]; } } } return sans-serif; }PickSystemFontName返回的是操作系统里实际存在的完整名称不是候选数组里的简写。这样能避免 “Microsoft YaHei” 在系统里实际叫 “微软雅黑” 导致匹配失败的问题。3. 把操作系统 Font 转成动态 TMP_FontAsset 的完整流程3.1 创建字体资产的参数清单拿到字体名称后先用Font.CreateDynamicFontFromOSFont创建 UnityEngine.Font 实例再传给TMP_FontAsset.CreateFontAsset。这一步就是整个方案的核心代码示例Font osFont Font.CreateDynamicFontFromOSFont(fontName, 32); if (osFont null) { osFont Font.CreateDynamicFontFromOSFont(sans-serif, 32); } TMP_FontAsset dynamicTmpFont TMP_FontAsset.CreateFontAsset( osFont, 32, // samplingPointSize 6, // atlasPadding GlyphRenderMode.SDFAA, // renderMode 2048, // atlasWidth 2048, // atlasHeight AtlasPopulationMode.Dynamic, // atlasPopulationMode true // enableMultiAtlasSupport );这里每个参数都对应着不同的坑逐个说。samplingPointSize建议用 32 到 36。采样点太小SDF 的精度不够文字放大后边缘容易发虚采样点太大图集里单个字形占用的区域变大同样的图集页能塞下的字数就变少。atlasPadding一般给 4 到 8。SDF 需要足够的内边距来记录字形边缘的远近场信息给 0 的话会出现相邻字形边缘互相污染的问题表现就是文字周围有奇怪的暗边。GlyphRenderMode.SDFAA是移动端的常用选择它得到的 SDF 带抗锯齿效果渲染效果比纯 SDF 更干净。注意这里选的是图集生成的渲染模式不是最终屏幕上的抗锯齿SDF 字体无论怎么缩放都不会像位图字体那样明显发糊。enableMultiAtlasSupport建议打开。动态字体在运行时会不断遇到新字形打开多图集支持后当前图集页写满会自动新增一页避免因为图集满了导致字形无法写入。3.2 材质与文本组件的绑定细节创建完 TMP_FontAsset 后直接在文本组件上赋值即可TMP_Text text GetComponentTextMeshProUGUI(); text.font dynamicTmpFont; text.fontSharedMaterial dynamicTmpFont.material; text.text 你好Unity;如果发现文字变成粉紫色说明材质的 Shader 没有正确绑定。较新的 TMP 版本在CreateFontAsset时会自动创建带正确 Shader 的材质但如果你对这个 FontAsset 做了复制或者是在某些资源重建流程中重新赋值了材质就需要手动矫正dynamicTmpFont.material.shader Shader.Find(TextMeshPro/Distance Field);另一个细节是不要把这些代码放在Awake里直接运行尤其是当场景里多个 TMP 文本组件同时被激活时。把字体创建和赋值拆到Start或者放到场景加载后的初始化协程里能避免渲染线程与主线程抢资源导致的卡顿。3.3 两个“动态”不是一回事很多人搞混一个概念Font.CreateDynamicFontFromOSFont创建的是系统字体对象的动态字体它解决的是“字形数据从哪里来”的问题而 TMP 的AtlasPopulationMode.Dynamic解决的是“字形什么时候烘进图集”的问题。两者配合才真正做到按需加载字形。具体表现是当 TMP 文本遇到一个当前图集里没有的字符时它会先从系统字体对象中获取字形轮廓然后实时烘到图集上再重建网格。这个流程在第一次显示某个冷门字时会有一次小卡顿但正常的聊天内容、界面文本都集中在常用字符上所以整体体验可以接受。4. 动态图集不是免费的内存、布局刷新与缺字兜底4.1 图集页无限膨胀切换语言后要主动清理动态图集的便利背后有一个实打实的成本图集只增不减。一个长期运行的项目如果不断出现新的生僻字或者用户手动输入的文本包含大量冷门字符图集页会慢慢膨胀。每张 2048×2048 的动态图集页在显存里大约占 16MB一旦涨到四五页这个方案的优势就被内存消耗抵消了。TMP 3.x 提供了清理方法ClearFontAssetData()可以在切换语言场景时调用dynamicTmpFont.ClearFontAssetData();这个调用会清掉动态烘进去的字形和图集数据下次再设置文本时会重新按需生成。这里建议加一个开关控制只在确认当前界面已经进入“不会立即显示大量历史文本”的状态时才清理避免刚清完又被同一批文本重新烘一遍白忙一场。4.2 第一帧不刷新 LayoutGroup 的经典故障系统字体的度量数据和每个平台、每个字体族相关。当运行时把某个 TextMeshProUGUI 的字体替换成系统字体后同一帧内VerticalLayoutGroup或ContentSizeFitter拿到的往往是旧字体的布局结果表现就是文字重叠、不换行、间距错乱但拖动一下窗口或者切一下分辨率布局又自己恢复了。这不是 Unity UI 的随机 bug而是布局系统在字体还没完成字形烘培时就计算了一遍等字形数据更新后布局没有收到重新计算的信号。解决办法是强制重建一次布局LayoutRebuilder.ForceRebuildLayoutImmediate(transform as RectTransform);更多时候问题出在时序上。如果字体是异步初始化的最简单的方式是等待一帧再重建布局yield return null; LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform);我在动态更换聊天字体、动态生成列表项标题时都会在首次设置文本后至少做一次延迟重建这样能避免大量由字体替换引发的布局抖动。4.3 缺字时用 fallback 栈兜底系统字体并不是万能的。某个设备如果缺少指定语言的字体或者用户输入的某个字符在当前系统字体里根本没有对应字形TMP 会直接在 UI 上显示一个方块。为了让这种情况不至于失控需要配置 fallback 字体栈。TMP 的 fallback 机制在TMP_Settings里维护了一个字体资源列表。当主字体的图集里找不到某个字形时TMP 会依次去 fallback 列表里的字体资源中查找TMP_Settings.fallbackFontAssets.Add(fallbackFontAsset);这里有一个工程经验不要在 fallback 列表里放一个巨大的全量中文字体。正确做法是放一个覆盖基础拉丁字符和常用符号的轻量字体用来兜住主字体缺失的零星字符。如果 fallback 字体本身也很重那系统字体方案省下来的包体又会被 fallback 吃回去。4.4 不要拿动态字体渲染所有 UI动态字体最大的价值在动态内容上也就是用户昵称、聊天消息、后台返回的富文本、多语言配置项这一类“运行时才确定”的文本。固定不变的主菜单标题、Logo 文字、带特殊样式的系统标题仍然建议用美术定制字体或者至少用静态 TMP 字体资产。原因很简单静态字体可以预先分配图集字体风格可控还能避免动态烘培对启动和首帧造成的压力。合理的设计是静态品牌字负责视觉门面动态系统字负责信息文本。两者共存而不是互相替代。5. 落到工程里实例缓存、降级路径和多语言切换5.1 字体资产全局只保留一份动态 TMP 字体资产一旦创建会持有系统字体对象和图集材质。如果每个场景都重新创建一次旧资产又没有被及时卸载内存就会不断累积。工程上的做法是设计一个FontService单例由它持有全局唯一的动态字体资产场景切换时不让它被销毁public sealed class FontService { public static readonly FontService Instance new FontService(); private TMP_FontAsset _dynamicFontAsset; public TMP_FontAsset GetDynamicFontAsset() { return _dynamicFontAsset; } }字体资产本身是 UnityEngine.Object可以在启动阶段创建后挂到 DontDestroyOnLoad 的 GameObject 上由场景管理器统一管理生命周期。这样既避免了重复创建也让全局字体替换成为可能。启动阶段不要一进主场景就立刻执行字体枚举和 TMP 字体创建这会和加载逻辑抢时间片。建议放在 Loading 界面之后、主界面出现之前用一个协程分步执行每步之间yield return null让 UI 先渲染一帧避免卡在白屏或 Logo 画面。5.2 真机返回 null 或者字体名称对不上的降级方案就算代码里写好了候选表真机仍可能出现CreateDynamicFontFromOSFont返回 null 的情况尤其是一些定制 ROM 会裁剪字体列表。这时不能直接崩溃要准备一条降级链private static Font CreateOSFontWithFallback(string fontName, int size) { Font font Font.CreateDynamicFontFromOSFont(fontName, size); if (font null) { font Font.CreateDynamicFontFromOSFont(sans-serif, size); } if (font null) { font Resources.GetBuiltinResourceFont(LegacyRuntime.ttf); } return font; }Resources.GetBuiltinResourceFont(LegacyRuntime.ttf)是 Unity 内置的兜底字体虽然不算美观但至少保证文本可见。生产环境还要把这些失败信息记到日志或上报系统里后续通过统计修正候选字体表。5.3 多语言切换的完整替换顺序多语言项目在运行时切换语言时不能只是把文本翻译换掉还得把字体一起换掉。我建议的顺序是根据目标语言重新调用PickSystemFontName获取新字体名称。创建新的Font和新的TMP_FontAsset。遍历当前活动 UI 上的全部 TMP 文本组件把font属性切换到新资产。等待一帧执行LayoutRebuilder.ForceRebuildLayoutImmediate。确认新字体渲染正常后再释放旧字体资产调用Resources.UnloadUnusedAssets。这一步一定要先建新的再释放旧的。如果先释放旧字体而当前场景还在显示旧文本屏幕上会出现一整片无法解析字形的方块直到新字体完全接管。5.4 这套方案的建议使用边界如果你的项目是以界面展示为主、强调版权字体品牌的单机游戏或者有非常强烈的美术字体风格我不建议全面采用系统字体方案。用字体本身做设计语言的项目品牌字才是核心资产系统字体再轻也没有意义。反过来如果你在做的是一款工具类应用、海外多语言小程序、轻量级社交产品或者因为包体限制想做极致压缩那这套“系统字体 动态 TMP fallback 兜底”的架构几乎就是成本最低的字库解决方案。我自己实践后的最后一条原则是把字体当成一个可热更的运行时配置来管理而不是写成死代码。系统字体名称、fallback 列表、采样点大小都放到配置表里线上发现某个机型字体重影或者缺字时不用发版改一行配置就能把问题修掉。这个思路比任何一步具体实现都更能帮项目省时间。本文还有配套的精品资源点击获取