☰
Unity动态TMP字体:不导入字体文件,按需读取系统字体实现多语言适配
2026/10/20 9:13:42 网站建设 项目流程

简介:UnityNativeOSFont是一款面向Unity开发者的开源插件,解决TextMeshPro(TMP)无法直接调用操作系统已安装字体的问题,其C#脚本在运行时获取系统字体列表并转换为TMP可用格式,适用于电子书、教育软件、本地化产品等需要高度自定义文本的场景,对中高级开发者尤为实用。资源包为zip压缩格式,共107个文件,仅1.33MB,轻量易部署,包内含Unity资产文件、C#脚本、13个ShaderLab着色器、4个cginc依赖文件,以及材质、预设、示例场景、文档与一款TTF字体,能够完整展现从字体获取到动态渲染的流程。目前已有1046人浏览学习,具有一定的社区参考热度。借助附带的示例场景,可迅速将一个系统字体绑定到TMP组件,并通过自定义Shader实现抗锯齿、阴影、描边等效果;同时需留意移动端频繁加载字体的性能开销,合理设计缓存是流畅运行的关键。

1. UnityNativeOSFont:不重新导入字体,动态 TMP 直接读系统字体

刚拿到这个项目时,我最意外的是它没有把“字体文件打包进 Unity”当成前提条件,而是反着做:用原生层去枚举操作系统已经安装的字体,再把选中的字体动态生成成 TextMeshPro 字体资产。也就是说,原先需要反复导入 TTF/OTF、做字体子集、维护一堆 TMP_FontAsset 的流程,在这里可以被替换成“运行时读系统字体——动态 TMP——直接赋给 UI 文本”。这套思路特别适合多语言版本、用户可自选字体的界面,以及不想让包体被多语言字体撑大的项目。如果你已经接触过 TextMeshPro 的动态字体和 SDF 渲染,会发现它真正把“系统字体”和“TMP 动态字体”接上了。

2. 总体设计与核心链路:三个模块如何把系统字体变成动态 TMP

2.1 原生字体枚举层:Windows 走系统 API,其他平台如何兜底

整个插件在结构上拆成三层:原生插件层、C# 桥接层、TMP 生成层。原生层只负责一件事——找出当前操作系统上有哪些字体。在 Windows 上,常见做法不是直接扫C:\Windows\Fonts目录,而是调 DirectWrite 的字体集合接口,因为目录下同一族字体可能有 TTC 集合、隐藏字体和别名,直接按文件名枚举很难对应到 UI 上显示的字体家族名称。macOS 一般走 CoreText,Linux 则用 fontconfig 的列表接口。

原生层暴露给 C# 的接口只返回扁平的字符串,不返回结构体,这样跨平台内存布局问题会少很多:

[DllImport("NativeOSFontPlugin")] private static extern IntPtr GetSystemFontList(); [DllImport("NativeOSFontPlugin")] private static extern int GetSystemFontCount(); [DllImport("NativeOSFontPlugin")] private static extern void FreeSystemFontList(IntPtr ptr);

三个接口各司其职:GetSystemFontList返回一段连续内存的指针,里面按分隔符拼接每个字体的家族名和文件路径;GetSystemFontCount让 C# 侧知道这段数据有多长,避免越界读取;FreeSystemFontList负责释放原生层分配的内存。这里最需要注意的是拿到IntPtr后不能直接把它当字符串用,要立刻复制到托管字符串,再调用释放接口,否则编辑器切场景时容易出现内存泄漏。

在目标平台不支持运行时动态库的场景下(比如 iOS),这套原生服务的兜底方案是把函数声明改成[DllImport("__Internal")],把原生代码静态链接进最终的二进制。但这套现有实现的主要测试环境还是桌面端,我在 Windows 和 Linux 上跑得最多,Android 上字体名规则差异比较大,后面第 4 章会专门说。

2.2 动态字体资产生成与 ShaderLab 材质的配套

C# 桥接层拿到字体路径后,核心工作是把一个 OS 字体生成TMP_FontAsset。如果直接使用 Unity 内置new Font(path)会遇到路径编码问题,所以这套源码最终是走原生层给出的路径,再交给 TMP 的创建接口:

public TMP_FontAsset CreateDynamicTMPFont(string fontPath, int samplePoints = 32) { if (string.IsNullOrEmpty(fontPath) || !File.Exists(fontPath)) return null; Font osFont = new Font(fontPath); if (osFont == null) return null; TMP_FontAsset asset = TMP_FontAsset.CreateFontAsset( osFont, samplePoints, 9, GlyphRenderMode.SDFAA, 256, 256); asset.atlasPopulationMode = AtlasPopulationMode.Dynamic; Shader shader = Shader.Find("TextMeshPro/Distance Field"); asset.material = new Material(shader); asset.material.EnableKeyword(ShaderUtilities.Keyword_Texture); return asset; }

几个参数值得说明一下。samplePoints决定 SDF 采样的字体粒度,32 是比较稳妥的启动值,想要更锐利的小字号可以提到 48;GlyphRenderMode.SDFAA是 TMP 动态字体最常用的模式,抗锯齿效果好;256×256 是初始图集尺寸,动态字体会按需往里填字形。最后一步很关键:新创建出来的TMP_FontAsset如果没有配套材质,UI 上渲染出来会是一整块粉紫色或者纯白方块。

这里和 ShaderLab 的关系就体现出来了。TMP 的 ShaderLab 变体不少,桌面端用TextMeshPro/Distance Field,移动端常用TextMeshPro/Mobile/Distance Field。低端机型或者 Vulkan 环境下,某些材质变体可能因为全局着色器设置没有被收集进构建,运行时Shader.Find返回空对象。我在接入时会在构建配置里把 TMP 自带的 ShaderLab 文件加进 Always Included Shaders,而不是赌运气。

参数推荐值说明
samplePoints32 或 48字号偏大就提高,过小会导致字形边缘发虚
glyphRenderModeSDFAA动态字体建议不要用 SMOOTH,图集扩展不够稳定
atlas 初始尺寸256×256中文场景建议直接 512×512,避免反复扩展
ShaderTextMeshPro/Distance Field移动端按平台换 Mobile 变体

2.3 使用场景与制约:什么项目值得用这套动态 TMP

动态读取系统字体不是万金油,它解决的是两件事:包体体积、系统语言适配。如果项目面向桌面自助机、Kiosk 系统或企业内部工具,操作系统字体基本固定,用这套方案可以把所有预置字体文件从包里挪出去。再者,系统语言切换后,字体族名称会随之变化,动态字体资产可以在运行时重建,UI 不需要维护一套“语言→字体 Asset”的映射表。

方案包体影响运行开销适配系统语言
预置字体 Asset字体文件全部进包,体积大加载快,可控性强需要提前打入多语言字体
动态字体自选文件包体较小,但字体仍要放包里按需取字形,首次渲染稍慢需要手动指定字体文件
读系统字体生成 TMP包体最小,不需要带字体原生枚举耗时,图集会重建天然跟随系统

代价也很明显:原生枚举一次字体可能要几百毫秒到一秒,不能每帧调用;动态字体图集在字形数量大时会动态扩容,期间可能出现卡顿;另外字体授权要特别注意,动态读取本地字体给单个用户使用通常没问题,但如果你把读到的字体文件打包进 AssetBundle 再分发,版权上很容易翻车。

3. 落地与运行:一个 Unity 工程接进去要改哪些地方

3.1 工程目录结构与包导入方式

源码放进 Unity 工程后,我习惯整理成 Runtime 和 Editor 分离的结构,避免逻辑被打包进发布版本:

Assets/ NativeOSFont/ Runtime/ NativeOSFontPlugin.cs SystemFontProvider.cs TMPDynamicFontFactory.cs FontPriorityList.cs Editor/ SystemFontCatalogGenerator.cs NativeOSFontSettings.cs Plugins/ Windows/x86_64/NativeOSFontPlugin.dll Linux/x86_64/libNativeOSFontPlugin.so macOS/NativeOSFontPlugin.bundle Samples/ FontSwitcherDemo.unity FontSwitcherDemo.cs

Runtime目录下放的是运行时真正会执行的 C# 代码;Editor目录里的SystemFontCatalogGenerator用于在编辑器里扫描系统字体,并把结果缓存成 ScriptableObject;Plugins目录下的原生动态库按照平台架构分开放置。导入时不要把整个源码仓库直接丢进 Assets,而是在package.json里声明一个本地包,用 Package Manager 的Add package from disk方式加载,这样后续升级、移除、写依赖都更干净。

3.2 编辑器脚本生成字体清单

运行时枚举系统字体的开销比较大,特别是在 Windows 上第一次扫描可能要数百毫秒。这套源码更推荐的做法是:在编辑器阶段制作一份字体目录,把字体家族名缓存进 ScriptableObject,运行时直接引用这份清单,只在用户需要下载字体时再访问原生层。

[MenuItem("NativeOSFont/Refresh Catalog")] public static void RefreshCatalog() { var catalog = AssetDatabase.LoadAssetAtPath<NativeOSFontCatalog>( "Assets/Resources/FontCatalog.asset"); if (catalog == null) { catalog = ScriptableObject.CreateInstance<NativeOSFontCatalog>(); AssetDatabase.CreateAsset(catalog, "Assets/Resources/FontCatalog.asset"); } catalog.fontNames = SystemFontProvider.GetInstalledFonts(); catalog.fontPaths = new string[catalog.fontNames.Length]; EditorUtility.SetDirty(catalog); AssetDatabase.SaveAssets(); }

这段编辑器代码的作用是,把当前开发机上安装的字体名称写进Assets/Resources下的资源文件。因为只缓存字体名,不缓存绝对路径,所以这份目录可以被多人共用;文件路径在另一台机器上可能完全不同,但字体家族名通常保持一致。实际改动中,我会让fontNames和fontPaths同时缓存,只是路径字段不参与发布,仅用于编辑器下的预览模拟。

3.3 运行时切换字体与回退顺序

运行时拿到字体路径后,最常见的问题是只替换了TMP_Text.font,没有替换材质。TMP 的字体组件内部缓存比较敏感,改了字体不改材质,渲染结果往往是旧字体的网格配新字体,出现各种错位。更稳的替换方式是连材质一起更新。

public void ApplyFontToAll(TMP_FontAsset fontAsset) { var allTexts = FindObjectsByType<TextMeshProUGUI>(FindObjectsSortMode.None); foreach (var tmp in allTexts) { tmp.font = fontAsset; tmp.fontSharedMaterial = fontAsset.material; tmp.ForceMeshUpdate(); } }

ForceMeshUpdate是强制网格重建,不然旧文本的顶点缓存会一直保留到下一帧自然刷新。在列表或聊天界面里大量切换字体时,不要一次性对几百个文本组件全部调用,可以改成只对当前显示的组件更新,滚动时再补充。回退顺序方面,这套源码给了FontPriorityList的概念:优先匹配 UI 语言对应的字体,找不到就回退到 TMP 默认字体,不会让整个界面直接崩掉。

3.4 构建前要确认的三件事

第一,原生插件的架构必须和打包目标匹配,Windows 打 x86_64,Linux 打 x86_64,真机部署就换成对应 ARM 版本,否则运行时会报DllNotFoundException。第二,如果场景中不引用任何 TMP 的 ShaderLab 资源,项目设置里的 Always Included Shaders 要手动加入 TMP 的 Distance Field 材质。第三,字体目录如果已经生成,确保它被打包进Resources或者对应 AssetBundle,否则运行时拿到的目录组件一直是空列表。

4. 避坑指南:系统字体和 TMP 结合时最容易翻车的四个场景

4.1 字体文件路径带中文,读出来是乱码

现象:Windows 下从原生层拿到的字体名看着正常,但拼接出的完整字体路径一旦包含中文目录,new Font(path)返回空对象,日志里打出的路径是一串问号或者乱码。

原因:原生层返回字符串时用了 UTF-8 编码,C# 侧如果直接把IntPtr转成默认 ANSI 字符串,在中文 Windows 区域内就会出现解码错位。字体路径本身进入 Unity 的FontAPI 时也会被隐式转换,最终无法加载。

解决:原生层统一用 UTF-8 输出字符串,C# 侧显式指定Encoding.UTF8.GetString来解析。我后来直接把 Windows 枚举逻辑改成读取注册表字体表,再转成完整路径,避免在字符编码上继续耗时间。

4.2 动态字体图集扩容无效,字形一直缺失

现象:初始化建了 256×256 图集,等界面输入长文本后,某些生僻字不显示,控制台还会出现类似 “glyph not found” 的日志。

原因:动态字体创建时如果把GlyphRenderMode设成了SMOOTH之类的非 SDF 模式,图集在扩展阶段不一定能正确重建,TMP 的动态扩容逻辑会失效。另一个常见原因是初始图集太小,首次写入几百个汉字就把图集占满,而扩容策略未触发。

解决:创建字体时强制用GlyphRenderMode.SDFAA,并把初始化图集从 256×256 提到 512×512,尤其当同一动态字体需要同时覆盖中文、日文、韩文时。这个参数是在TMP_FontAsset.CreateFontAsset的倒数后三个参数里,改一次就能明显减少运行时重建频率。

4.3 Android 与 PC 的字体名不一致,查表查不到

现象:在编辑器里能找到 “Noto Sans CJK SC” 这个字体,发布到 Android 后同一个字体在目录里显示成 “Noto Sans CJK”,UI 按名称匹配失败,全部回退到默认字体。

原因:不同平台对字体家族名称的翻译规则不一样,Android 的字体系统会对家族名做归一化处理,而 Unity 的Font.GetPathsToOSFonts()返回的是系统原始名称。拿 PC 端缓存的目录去匹配移动端,天然不稳定。

解决:匹配逻辑从“按名称精确匹配”改成“按字体指纹匹配”,比如把文件名、PostScript 名称或者字体元数据里的名称都拿出来做候选。Android 上结合系统的 fallback 字体规则,先拿家族名列表,再在最坏情况下走默认字体,不让 UI 因为匹配不到而崩溃。

4.4 原生插件回调在主线程外,Unity 编辑器直接假死

现象:调用一次GetSystemFontList后,Unity 编辑器卡住,看不到任何错误日志,鼠标一直转圈,最后只能强制结束进程。

原因:原生代码在扫描字体时使用了独立线程,扫描完成直接调用UnitySendMessage想通知 Unity 刷新,但UnitySendMessage要求必须从主线程调用。原生层拿到字体的瞬间,回调发生在线程上,Unity 侧接到的是一个跨线程调用,编辑器直接卡死。

解决:原生层不回调任何 Unity API,只返回字符串数据;C# 侧用自己的线程调度方式处理,比如放到Task.Run里异步等待,或者明确只在主线程同步调用。从那以后我维护这份原生代码时始终要求一条纪律:原生导出函数必须是纯 C 风格,不允许 include 任何 Unity 头文件,也不允许主动调用 Unity 的发送消息接口。

5. 二次开发:把单品插件改成一套可配置字体策略

5.1 替换接口:在字体提供者上做策略隔离

正式项目里不建议所有脚本直接静态调用TMPDynamicFontFactory,那样后续想加入 AssetBundle 字体或远程下载字体时,要改的调用点会非常多。更好的做法是先定义一层接口:

public interface IFontProvider { TMP_FontAsset GetFont(string languageTag, string fontName); IReadOnlyList<string> AvailableFonts { get; } }

系统字体实现走原生枚举,Bundle 字体实现走 AssetBundle 内字体,Remote 实现走 CDN 下载临时字体。UI 层只依赖IFontProvider,真正决定“语言优先还是用户手动选择优先”的逻辑放在FontPriorityList里,做成 ScriptableObject,让策划在 Inspector 里配置优先级顺序就行。新增一种字体来源时不动 UI 代码,这是这套方案最有价值的扩展点。

5.2 验证方法:怎么证明新字体策略没有坑

改造完字体策略后,光靠肉眼验证永远不够,我会补一组自动化冒烟检查:每个返回的字体名都要能在AvailableFonts里找到;给同一段文本应用不同字体,字符宽度差异要能被TextGenerator.GetPreferredValues捕获;故意传入不存在的字体名,回退要落到默认字体而不是抛异常;图集扩容后内存不能持续增长。这些检查挂在编辑器菜单里,每次改完原生层先跑一遍再合入。多语言 UI 最怕的就是字体静默回退,用户看不出报错但排版全乱。希望这些整理能帮到你,让系统字体和动态 TMP 的组合不再是一个玄学问题。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询