Unity运行时加载系统字体:TMP动态字体资产生成与避坑指南 简介这是一套面向Unity开发者的开源字体适配方案核心价值在于动态获取操作系统本地字体并接入TextMeshProTMP组件解决默认TMP字体库无法覆盖全平台字体的问题适用于电子书、教育软件及本地化游戏等需要自定义文本展示的场景。压缩包共107个文件、约1.33MB精简且结构清晰核心C#脚本负责运行时读取系统字体列表shader与cginc文件用于调整字体渲染效果实现抗锯齿、描边等视觉优化多个asset与mat预置了SDF字体资源和材质可直接套用。配套的场景、PDF文档和Readme文件降低了上手门槛开发者可根据项目需求快速集成并通过自定义Shader进一步提升跨设备可读性与美观度。已有1045人学习下载对于需要提升文本表现力并控制包体体积的Unity项目这是一份实用且易扩展的参考实现。1. 为什么你的 TMP 字体永远慢半拍UnityNativeOSFont 要解决的是运行时加载系统字体这件事做 Unity 项目的同学大概率遇到过这种需求用户在设置界面想换一个显示字体结果发现 TMPTextMeshPro的字体资产是打包时写死的想加一个字体就得重新出包或者做了一个多语言工具日文韩文阿拉伯文缺字形但项目包里不可能把所有语言字体都塞进去。UnityNativeOSFont 这个方向解决的正是这个问题——在运行时直接读取操作系统已安装的字体文件动态生成 TMP 的 FontAsset不让字体选择受限于打包内容。这一类方案的典型使用场景是编辑器工具、本地化工具、UI 自定义设置面板、以及需要展示用户本地字形的聊天或文档类应用。它适合的人群很明确已经熟练使用 TMP但被字体资产管理和多语言字形缺失卡住的技术美术或客户端工程师。一个反直觉的结论是Unity 自带的 Font.CreateDynamicFontFromOSFont 并不可靠尤其在 Windows 上经常拿到缺失的字体名而在 macOS 上更是时好时坏。与其和这个黑匣子较劲不如直接通过原生层枚举系统字体文件路径再交给 TMP 的 Dynamic OS Font 特性去加载——这才是 UnityNativeOSFont 这类实现的核心思路。本文会沿着「系统字体有哪些 → 路径怎么拿 → TMP 怎么吃进去 → 踩了什么坑 → 还能怎么优化」这条线把方案完整铺开。2. 系统字体枚举Unity 原生层到底提供了什么缺口在哪2.1 Unity 自带的 Font.GetOSInstalledFontNames 为什么不够用Unity 的 UnityEngine.Font 类里有一个静态方法 GetOSInstalledFontNames很多人第一反应是拿它枚举字体。但实际用下来会发现几个硬伤第一它只返回字体名字符串数组不返回文件路径第二在 Windows 上它返回的是字体显示名称比如 Microsoft YaHei UI可这个名称和字体文件的实际文件名不一定一致第三在 macOS 上它返回的是 PostScript 名称和用户在字体册里看到的名字是两套体系。最关键的是TMP 的 FontAsset 创建需要走的路径是 TMP_FontAsset.CreateFontAsset里面接收的是 Font 对象或字体文件路径而不是一个显示名称。也就是说Unity 给了你一个「列表」但没给你「门牌号」。你拿着名字去找文件得自己遍历系统字体目录做匹配。这还不是最头疼的——Windows 的字体注册表里同一个字体族可能有多个字体文件比如微软雅黑有常规、粗体、细体它们在注册表里的字体名都叫 Microsoft YaHei但文件是不同的。如果你只按名称匹配经常拿到的不是你想要的那个字重文件。2.2 跨平台字体目录的常规路径与我的选型思路既然原生方法不给路径就得自己做枚举。常见做法是直接扫系统字体目录我在几个平台上会这样做WindowsC:\Windows\Fonts配合注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts 读取字体名到文件路径的映射macOS/System/Library/Fonts 和 /Library/Fonts以及用户目录 ~/Library/FontsLinux/usr/share/fonts 和 /usr/local/share/fonts但只扫目录有个问题目录里的文件名经常是字体内部名的缩写比如 macOS 上 PingFang.ttc 是苹方字体的容器文件。这种情况下我会优先用原生 API 拿到对外的字体显示名再通过文件系统的修改时间、字体内部元数据来做映射。这里有个取舍我用的是「目录扫描 字体名白名单过滤」的方式而不是纯注册表。因为注册表虽然准确但只覆盖 Windows目录扫描是跨平台的最小公倍数方案。提示如果你只做 Windows 工具注册表方式是优先级最高的别用目录扫描否则会遇到中文字体文件名乱码的问题。2.3 枚举脚本一个 C# 层的轻量封装下面是我在 Unity 项目里用的枚举入口。这个脚本不依赖任何第三方库只需要 Unity 的 UnityEngine 和 System.IO。using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using UnityEngine; public static class SystemFontEnumerator { private static readonly string[] WindowsFontDirs { C:\Windows\Fonts }; private static readonly string[] MacFontDirs { /System/Library/Fonts, /Library/Fonts, ${System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile)}/Library/Fonts }; private static readonly string[] LinuxFontDirs { /usr/share/fonts, /usr/local/share/fonts }; private static readonly string[] FontExtensions { .ttf, .otf, .ttc }; public static Liststring GetSystemFontFilePaths() { Liststring results new Liststring(); string[] dirs GetFontDirsByPlatform(); foreach (string dir in dirs) { if (!Directory.Exists(dir)) continue; foreach (string ext in FontExtensions) { results.AddRange(Directory.GetFiles(dir, $*{ext}, SearchOption.AllDirectories)); } } return results; } private static string[] GetFontDirsByPlatform() { if (Application.platform RuntimePlatform.WindowsEditor || Application.platform RuntimePlatform.WindowsPlayer) { return WindowsFontDirs; } else if (Application.platform RuntimePlatform.OSXEditor || Application.platform RuntimePlatform.OSXPlayer) { return MacFontDirs; } else if (Application.platform RuntimePlatform.LinuxEditor || Application.platform RuntimePlatform.LinuxPlayer) { return LinuxFontDirs; } return new string[0]; } }这段代码的逻辑很简单按平台返回字体目录数组然后用通配符模式递归抓取所有 ttf、otf、ttc 文件。Directory.GetFiles 的 SearchOption.AllDirectories 会扫子目录避免漏掉 Linux 上常见的一层嵌套结构。注意这里我用的是扩展名过滤而不是 MIME 类型因为 Unity 的 AssetDatabase 和 TMP 只认文件扩展名。2.4 为什么不直接 P/Invoke 原生 API有同学会问既然叫 UnityNativeOSFont是不是应该直接写 C 或者 Objective-C 来调系统 API这个方案我在早期确实试过在 macOS 上用 CoreText 枚举字体Windows 上用 DirectWrite。但后来放弃了原因很实际Unity 的 TMP 在加载字体时本质上是把字体文件给 FreeType 或系统字体引擎去 rasterizeTMP 在 Editor 和 Runtime 下的实现差异很大。与其维护一份原生插件不如在 C# 层拿路径剩下的交给 TMP 自己的动态字体逻辑。这样做最大的收益是不用为 IL2CPP、不用为各平台的 AOT 编译做特殊处理出包简单。我一般只会在两种情况下才上原生插件一是你需要读取字体文件内部的品牌信息、字体族名这些元数据二是你的目标平台是 iOS 或 Android系统字体目录的访问权限受限。Unity 编辑器下跑通 Windows 和 macOS 是最常见的第一步。3. 从字体文件路径到 TMP FontAsset动态资产生成的最小可跑通流程3.1 TMP 动态字体的两种加载方式与选型TMP 加载系统字体其实有两条路。第一条是 TMP_FontAsset.CreateFontAsset(Font font)传入 UnityEngine.Font 对象第二条是 TMP_FontAsset.CreateFontAsset(string filePath)传入字体文件路径。区别在于第一种在运行时需要先把系统字体文件拷进 Unity 的 Font 对象里Font.CreateDynamicFontFromOSFont 就是干这个的但它经常返回 null第二种是直接让 TMP 从文件系统读文件路径有效就能加载不需要预先创建 UnityEngine.Font。我会优先用路径方式。因为路径方式是「文件驱动」的只要文件存在TMP 就会去解析而 Font 对象方式是「对象驱动」的Unity 层的字体引擎可能在创建对象时就失败了你连排查的机会都没有。上述两种方式在 Windows 和 macOS 上的表现差异非常明显路径方式在 macOS 加载 ttc 文件时表现稳定Font 方式则经常在 ttc 上返回空对象。3.2 完整加载流程枚举、过滤、创建、赋值下面的流程在编辑器下运行时可以直接跑通放在一个 MonoBehaviour 的 Start 方法里作为演示入口。using System.Collections.Generic; using System.IO; using TMPro; using UnityEngine; public class DynamicFontLoader : MonoBehaviour { public TextMeshProUGUI targetText; void Start() { // 1. 枚举系统字体 Liststring fontPaths SystemFontEnumerator.GetSystemFontFilePaths(); Debug.Log($Found {fontPaths.Count} font files.); // 2. 过滤出自己关心的字体这里以微软雅黑为例 string targetPath FindFontByKeyword(fontPaths, msyh); if (string.IsNullOrEmpty(targetPath)) { Debug.LogError(Font not found.); return; } // 3. 用文件路径创建 TMP 字体资源 TMP_FontAsset dynamicFont TMP_FontAsset.CreateFontAsset(targetPath); if (dynamicFont null) { Debug.LogError(TMP_FontAsset.CreateFontAsset failed.); return; } // 4. 给文本赋值 targetText.font dynamicFont; targetText.text 运行时加载的字体示例微软雅黑。; } private string FindFontByKeyword(Liststring paths, string keyword) { foreach (string path in paths) { string fileName Path.GetFileNameWithoutExtension(path).ToLower(); if (fileName.Contains(keyword)) { return path; } } return null; } }CreateFontAsset 的路径版实现是 TMP 在较新版本中提供的它内部会通过 FontEngine 读取字体文件并生成 SDF 图集。需要特别注意的是这个接口创建出来的 FontAsset 是「动态字体」也就是图集是按需生成的不会一开始就把所有字形都烘焙进去。你不需要预先准备字符表但第一次显示某个生僻字时会有轻微卡顿因为要现场生成字形。3.3 参数设置哪些值是文本渲染效果的关键CreateFontAsset 有一个重载可以指定采样点大小和图集尺寸默认值分别是 90 和 512。在动态加载的时候我的经验是不要用默认值硬扛。如果 UI 里最大字号是 60建议采样点设为 120 或更高否则字会发虚。图集尺寸方面动态字体建议至少 1024x1024因为一旦图集不够用TMP 会有 fallback 机制去扩展但那是拿性能换的。TMP_FontAsset dynamicFont TMP_FontAsset.CreateFontAsset( targetPath, 120, // sampling point size 1024, // atlas width 1024, // atlas height TMPro.AtlasPopulationMode.Dynamic, TMPro.FaceStyles.Normal, 1f, // font weight 1f, // normal style 1f, // bold style 0.5f, // character spacing 1f // line spacing );这些数字不要背按项目 UI 实际字体大小调整即可。核心是 sampling point size 要大于 UI 中会用到的最大字号——这就是不少团队遇到的「TMP 字体在运行时加载后模糊」多半是采样点太低图集把字形压缩了。3.4 运行时的重载策略不要每次打开面板都重新创建一个常见误用是每次用户打开字体设置面板就调一次 CreateFontAsset这会在帧率上卡出明显峰值。正确做法是做一个简单的缓存容器以字体文件路径为 key把创建好的 TMP_FontAsset 存进字典里。下面这个封装可以直接粘到项目里用。using System.Collections.Generic; using TMPro; using UnityEngine; public static class FontAssetCache { private static Dictionarystring, TMP_FontAsset _cache new Dictionarystring, TMP_FontAsset(); public static TMP_FontAsset GetOrCreate(string fontFilePath, int samplingPointSize 120) { string key ${fontFilePath}_{samplingPointSize}; if (_cache.TryGetValue(key, out TMP_FontAsset cached)) { return cached; } TMP_FontAsset fontAsset TMP_FontAsset.CreateFontAsset(fontFilePath, samplingPointSize, 1024, 1024); if (fontAsset ! null) { _cache[key] fontAsset; } return fontAsset; } public static void Clear() { foreach (TMP_FontAsset asset in _cache.Values) { if (asset ! null) { Object.Destroy(asset); } } _cache.Clear(); } }Clear 方法在切换场景或卸载 UI 模块时调用避免动态生成的资产泄漏。注意 Destroy 是延迟到帧末执行的清空字典时指针不会立即失效但这不影响使用。4. 避坑与排查系统字体这条路上我踩过的 6 个坑4.1 坑Windows 上文件名是英文但注册表里是中文现象用 Directory.GetFiles 拿到了 msyh.ttc但 Font.CreateDynamicFontFromOSFont(微软雅黑) 返回 null。原因Unity 的字体引擎在 Windows 上需要通过 DirectWrite 的字体族名称来匹配而不是文件路径。文件路径能在 TMP 的 CreateFontAsset(string) 中直接使用但如果你绕回 Font 对象方式就必须用注册表里注册的名称。解决优先用路径方式不要用字体名方式。如果实在需要用 Font 对象从注册表读取映射关系再用映射后的文件路径创建 Font。我不会再相信字符串匹配。4.2 坑macOS 的 .ttc 集合字体第一次加载偶尔返回空现象加载苹方字体时CreateFontAsset 返回 null但文件路径是正确的日志里也没有异常。原因TMP 的 FontEngine 在处理 ttc 时有些版本不会自动选择字体集合里的第一个字体族遇到多字体族的 ttc 文件会直接失败。这和 FreeType 的行为一致——需要指定 face index。解决如果你的目标是 macOS建议优先找 .otf 或 .ttf 的单文件字体版本比如思源黑体SourceHanSans的单个 ttf如果只能用 ttc就需要调底层接口在 C# 层给 TMP 传一个 face index 参数但这个接口在不同 TMP 版本里不稳定。最简单可靠的方案是打一个 ttf 子集包放到 StreamingAssets运行时再加载。4.3 坑iOS 和 Android 上拿不到系统字体目录现象在真机上枚举不到任何字体文件路径目录不存在或没权限。原因移动平台的沙盒机制。Android 的 /system/fonts 目录在非 root 情况下不可读iOS 的字体目录也是只读且不确定的。解决移动端不要指望直接读取字体文件。我的方案是把需要在运行时显示的多语言字体打包进 Assets 或 StreamingAssets用 TMP 的 fallback 机制挂多个字体。UnityNativeOSFont 只作为编辑器下的辅助工具或 Windows 桌面端解决方案跨到移动端是另一套逻辑。注意如果你在 Android 上确实需要读取系统字体常见做法是通过 AndroidJavaObject 调 Typeface 的 getFontList 或直接拿系统字体文件的 asset 路径但这些在不同厂商 ROM 上差异很大维护成本极高我自己不推荐作为主方案。4.4 坑动态字体加载后中文显示成方框现象TMP 文本控件的 font 已赋值英文字母正常中文全是「口」或方块。原因字符集问题。TMP 的动态字体默认只保证 ASCII 和基本拉丁字符中文字符不在初始字符表里。动态模式下按理说应该自动按需生成但实际中遇到动态图集模式关闭或 fallback 列表被清空的情况。解决检查 TMP Settings 里 Default Font Asset 和 Fallback Font Assets 的配置确认你的动态字体 FontAsset 的 Atlas Population Mode 是 Dynamic如果还不行手动调用 fontAsset.TryAddCharacters 把常用中文字符表加入。注意这里 TryAddCharacters 传一个字符串即可比如常用的 3000 字中文列表。4.5 坑运行时加载的字体在打包后不见了现象编辑器下跑得好好的Build 出来后就加载失败。原因路径问题。编辑器下路径是绝对路径打包后 Application.dataPath 变成只读路径但系统字体目录还在原位理论上不应该失败。真正的坑是有些打包选项会裁剪掉 TMP 的运行时资源导致 FontEngine 某些模块缺失。解决在 Player Settings 的 Scripting Stripping 里把 TMP 相关类加入 preserve同时确认你的字体文件路径用的是绝对系统路径不是 Application.streamingAssetsPath 这种相对路径。另外IL2CPP 下把 TMP_FontAsset 加入 link.xml。这部分的血泪经验是先看日志里是否有 MissingMethodException 或者 NullReferenceException不是路径问题是裁剪问题。4.6 坑创建 FontAsset 时内存暴涨现象调用 CreateFontAsset 后内存上升 200MB 以上且 GC 后不下降。原因图集尺寸设太大且采样点太高。默认 512 图集不会炸但如果你按上面建议改成 1024 甚至 2048加上 120 的采样点单字体图集可能占 30MB 以上多个字体叠加就很夸张。解决按需设置不追求大而全。如果动数字体采样点 120、图集 1024 是合理的如果静态字体且只展示大标题用 2048 图集做一次性烘焙反而更好。要养成一个习惯动态字体创建后过一段时间检查一下 Profile 里的 Texture2D 数量。5. 进阶思路与验证方法把动态字体做成一套真正能上线的字体服务现在要聊的是怎么把这个坑踩得一劳永逸。纯跑通只是第一步一个生产级方案应该有更清晰的分层我会把它拆成枚举、路由、加载、释放四个模块。枚举模块已经在上文给出路由模块指的是从用户的语言设置和字体偏好中决定加载哪个字体文件加载模块负责创建 TMP_FontAsset 并做缓存释放模块则是处理场景切换、用户切换时的资源回收。一个常见的进阶做法是把系统字体枚举的结果映射到一个可搜索的 UI 列表列表项显示字体展示名和语言标记用户选择后真正加载时才调用 CreateFontAsset。这样不会在打开设置面板时一次创建几十个字体资产。另一个建议是给 TMP 文本组件做一个自定义脚本在设置 font 属性前先检查缓存缓存没有再去创建避免多处重复创建同一字体。验证方法上我的惯例是写一个编辑器下的集成测试用例模拟用户连续切换 10 种不同字体每切一次记录以下指标加载耗时、生成 FontAsset 后的内存增量、目标文本的渲染清晰度通过截图对比。加载耗时用 Stopwatch 卡一下只要每次超过 150ms就应该提前做异步加载。TMP 没有现成的异步接口我一般用协程在帧末调用 CreateFontAsset下一帧再赋值给文本保证 UI 不卡。还有一个容易被忽略的细节TMP 的字体回退机制可以和动态字体协同工作。比如你默认字体用打包好的思源黑体动态加载的微软雅黑作为 fallback这样即使用户选择的字体缺字形也不会出现方框。fallback 是按字符缺失逐个匹配的链不要建太长三层以内是可控的。最后想说的一个习惯是每当遇到 TMP 字体加载的怪问题我会先写一个最小复现工程里面只有一个 TextMeshProUGUI、一个加载脚本不开任何后处理、不做 UI 动画直接在空场景里验证。这个方法帮我解决了好几类诡异问题——包括前面提到的 ttc 加载失败和 IL2CPP 裁剪。环境干净问题才容易显形。系统的字体加载方案没有银弹但它能帮你把字体选择从打包期挪到运行期这本身就是一项很值回票价的工程投入。希望帮到你。本文还有配套的精品资源点击获取