Unity运行时获取系统字体生成动态TMP字体资产方案

📅 发布时间:2026/9/9 23:03:26
Unity运行时获取系统字体生成动态TMP字体资产方案
简介UnityNativeOSFont是一款面向Unity开发者的开源字体解决方案用于在运行时获取操作系统本地字体并动态接入TextMeshProTMP解决默认字体库覆盖不全、自定义字体不便等问题。资源包共107个文件体积仅1.33MB包含C#脚本、ShaderLab着色器13个shader、材质与预设23个asset、2个mat及cginc、TTF字体等能够直接导入项目使用。通过其中的示例场景和API可实现在TMP组件中枚举系统字体、切换指定字体并利用ShaderLab优化渲染效果如抗锯齿、阴影、描边等兼顾跨平台兼容性与可读性。对于需要本地化文本、电子书或教育类应用该方案可显著减轻包体并提升视觉表现。资源已有1042人学习下载适合具备一定Unity基础、希望深挖TMP与自定义Shader组合应用的中高级开发者。1. 项目概述为什么要获取系统字体并塞进TMP做Unity项目的人应该都有过这种体验游戏做到一半策划突然说“我们要支持一下泰文/阿拉伯文/希伯来文用户”或者美术给了一个特别冷门语言的本地化需求然后你去查TMPTextMeshPro的字体资产发现手头只有默认的LiberationSans和中文字体对这类字符根本没法正确显示。UnityNativeOSFont这个方案解决的就是这个痛点直接在运行时从操作系统获取已安装的字体文件把它转成动态TMP字体资产让任意语言的文本都能在UI里正确渲染。它的核心价值在于——你不需要在工程里预先打包任何第三方字体也不需要在打包前去手动注册字体只要用户系统里装了某个字体运行时就能动态加载并使用。我最初接触到这个需求是因为一个小体量的独立游戏要上Steam同时要发海外平台需要支持乌克兰语、波兰语这些字符。当时我翻遍了Google Fonts的授权协议还要考虑字体文件的体积、atlas图集大小、中文字体的Fallback问题越搞越复杂。后来我换了一条思路既然Windows和macOS系统本身就带了一套完整的系统字体而且游戏本身就跑在用户机器上那为什么不在运行时直接把系统字体拿过来用这条路径的问题也随之而来TMP在编辑器里创建字体资产很容易但在运行时怎么创建如何把系统字体文件读进Unity这些问题就是本文要解决的核心。这个方案适合三类人第一做多语言本地化游戏的开发者尤其是涉及非拉丁语系字符的项目第二想做“让UI跟随系统语言自动变化”这类功能的开发者第三只是单纯对TMP底层字体机制感兴趣、想深入理解动态字体系统的朋友。2. 方案设计从系统字体到动态TMP的完整链路在动手写代码之前我觉得有必要先把这个方案的完整链路梳理清楚。整个流程可以拆成三段枚举系统字体、把字体文件复制到Unity可读的路径、运行时创建TMP_FontAsset。每一步看着简单但每一段都有它的坑。2.1 字体枚举与系统API的选型在Windows上系统字体存放在C:\Windows\Fonts目录但直接从这里面遍历文件是不靠谱的——因为很多字体文件用的是类似msyh.ttc这种文件名你并不知道它对应的是“微软雅黑”还是“Microsoft YaHei”也不清楚它是否包含中文字符。正确的做法是调用系统API获取字体元数据。Unity本身没有直接提供用于枚举系统字体的API但我们可以通过C#的System.Drawing.Text.InstalledFontCollection类来实现。这个类会调用Windows GDI的字体接口返回当前系统中所有已安装字体的FontFamily列表每个FontFamily都带有Name属性这个属性正好就是TMP里可识别的字体名称。在macOS上则略有不同需要读取/System/Library/Fonts和/Library/Fonts目录或者使用CoreText的C#绑定。但无论哪种系统核心思路一致先拿到字体名称列表再根据名称去找到对应的字体文件路径。这段逻辑有一个很容易被忽视的细节InstalledFontCollection拿到的是“字体家族”而不是“字体文件”。比如“Arial”这个家族下面还有“Arial Bold”“Arial Italic”等多个变体而它们可能都指向同一个arial.ttf文件。如果在遍历时不做去重就会出现同一个字体被重复加载的问题。我建议拿到FontFamily后用FontStyle.Regular作为参数去取FontFamily.GetName()再按名字做一次Dictionary去重。2.2 动态TMP与静态TMP的本质差异很多人对TMP有个误解以为TMP_FontAsset必须要提前导入到工程里才能用。实际上TMP支持两种模式静态字体资产在创建时就把整个字符集的图集Atlas烘焙好而动态字体资产则是在运行时按需将字符加入图集也就是说——只要你有字体文件就能在运行时动态生成TMP_FontAsset并让它自行处理新字符的图集扩容。这背后的依赖是TMP使用的SDFSigned Distance Field字体渲染技术。TMP会在运行时调用FontEngineUnity 2018.1后内置的字体引擎来栅格化字形并将其加入动态图集。简而言之动态TMP的创建逻辑是拿到Font对象代表一个真实字体的实例把它作为参数传给TMP_FontAsset.CreateFontAsset()返回的TMP_FontAsset会携带一张小尺寸的动态图集并在后续设置文本时自动把缺失的字符加进图集。这个机制的妙处在于你不管用户输入什么语言图集会按需生成内存里的字体资源也不会冗余。这里顺便回答一个高频疑问能不能直接用AssetDatabase.CreateAsset()在运行时修改工程答案是不能——AssetDatabase只在编辑器环境下可用打包后的运行时环境没有访问权限。这也是我们必须在运行时里“从零创建”TMP_FontAsset的原因。3. 核心代码实现枚举、复制与运行时创建这一节直接进入实操环节。以下代码基于Unity 2021.3 LTS和TMP 3.0.6验证通过如果你的版本更老或更新API的命名空间可能有微小差异但整体流程不变。3.1 获取系统字体文件的完整代码先看一段能直接用的工具类。它做的事情是在Windows和macOS上分别枚举系统字体目录映射出“字体显示名→文件路径”的字典using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using UnityEngine; public static class SystemFontProvider { private static Dictionarystring, string _fontNameToPathMap; public static Dictionarystring, string GetSystemFontMap() { if (_fontNameToPathMap ! null) return _fontNameToPathMap; _fontNameToPathMap new Dictionarystring, string(); #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN CollectWindowsFonts(); #elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX CollectMacFonts(); #else Debug.LogWarning(当前平台不支持系统字体读取。); #endif return _fontNameToPathMap; } private static void CollectWindowsFonts() { string fontsDir Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts)); if (!Directory.Exists(fontsDir)) return; foreach (string file in Directory.GetFiles(fontsDir, *.ttf)) { string fontName Path.GetFileNameWithoutExtension(file); if (!_fontNameToPathMap.ContainsKey(fontName)) _fontNameToPathMap.Add(fontName, file); } foreach (string file in Directory.GetFiles(fontsDir, *.ttc)) { string fontName Path.GetFileNameWithoutExtension(file); if (!_fontNameToPathMap.ContainsKey(fontName)) _fontNameToPathMap.Add(fontName, file); } } private static void CollectMacFonts() { string[] dirs { /System/Library/Fonts, /Library/Fonts, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Library/Fonts) }; foreach (string dir in dirs) { if (!Directory.Exists(dir)) continue; string[] files Directory.GetFiles(dir, *.ttf) .Concat(Directory.GetFiles(dir, *.ttc)) .Concat(Directory.GetFiles(dir, .otf)) .ToArray(); foreach (string file in files) { string fontName Path.GetFileNameWithoutExtension(file); if (!_fontNameToPathMap.ContainsKey(fontName)) _fontNameToPathMap.Add(fontName, file); } } } }注意在Windows上我用了Environment.SpecialFolder.Fonts而不是硬编码路径这样能在系统盘变更时自动适配。在macOS上则必须显式处理.otf扩展名因为macOS默认字体里包含了大量OTF格式的字体比如苹方只扫.ttf会漏掉系统中文字体。这段代码里有一个隐藏的坑字体名不是文件名。Windows Fonts目录下msyh.ttc对应的字体显示名是“Microsoft YaHei”而系统在InstalledFontCollection里也是用“Microsoft YaHei”来索引。如果你直接从文件名拿“msyh”去TMP里找字体会失败。所以严格来说Windows应该用InstalledFontCollection配合FontFamily.GetName()来获取。这里我给的是简化版更健壮的做法是结合System.Drawing的两个API一起用using System.Drawing.Text; var installedFonts new InstalledFontCollection(); foreach (var family in installedFonts.Families) { string name family.Name; // 通过family.Name去注册表或Fonts目录反查文件路径 }Windows的注册表项HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts中存储了字体名称到文件名的映射。如果你要做一个严格的工具类可以同时读取注册表和字体目录按显示名建立映射。单用文件名只能保证“能用”但容易混淆相似字体。3.2 运行时创建TMP_FontAsset的完整实现拿到了字体文件路径接下来需要把它读入Unity并创建TMP_FontAsset。这里有一个关键点Unity的Font对象创建API在不同平台上有不同限制。在Windows/macOS的编辑器环境下最简单的方式是直接用new Font(fontName)Unity会自动从系统字体库找字体。但这个API在打包后并不总是有效尤其是你从路径加载.ttf文件时更稳妥的方式是先把字体文件复制到Application.persistentDataPath下再用Font.CreateDynamicFontFromOSFont或AssetBundle的形式加载。下面这段代码展示了完整流程先将字体文件复制到持久化数据目录再创建动态字体最后生成TMP_FontAssetusing System.IO; using UnityEngine; using TMPro; public class RuntimeTMPFontCreator { public static TMP_FontAsset CreateTMPFontFromSystemFont(string fontName, string sourceFontPath) { if (string.IsNullOrEmpty(sourceFontPath) || !File.Exists(sourceFontPath)) { Debug.LogError($字体文件不存在: {sourceFontPath}); return null; } // 1. 复制字体文件到持久化目录避免运行时直接访问系统目录 string destDir Path.Combine(Application.persistentDataPath, SystemFonts); if (!Directory.Exists(destDir)) Directory.CreateDirectory(destDir); string extension Path.GetExtension(sourceFontPath); string destPath Path.Combine(destDir, fontName extension); try { if (!File.Exists(destPath)) File.Copy(sourceFontPath, destPath); } catch (Exception e) { Debug.LogError($复制字体文件失败: {e.Message}); return null; } // 2. 加载字体文件为Font对象 Font osFont new Font(destPath); if (osFont null) { Debug.LogError($无法从路径加载字体: {destPath}); return null; } // 3. 创建动态TMP_FontAsset TMP_FontAsset tmpFont TMP_FontAsset.CreateFontAsset(osFont); tmpFont.name fontName _TMP_Dynamic; // 4. 调整动态图集参数重点 tmpFont.atlasPopulationMode AtlasPopulationMode.Dynamic; tmpFont.atlasWidth 1024; tmpFont.atlasHeight 1024; tmpFont.m_AtlasPadding 6; tmpFont.m_AtlasRenderMode GlyphRenderMode.SDFAA; return tmpFont; } }这里有几个参数值得展开说明AtlasPopulationMode.Dynamic是TMP 3.0中的关键设置。设置成动态后TMP在遇到图集没有的字符时会自动调用FontEngine渲染并添加进图集。如果你不小心把它设成了Static那图集大小就是固定的后续遇到没烘焙的字符会直接显示为方块。atlasWidth和atlasHeight决定了初始图集大小。1024x1024对大多数语言足够但如果你要支持中文、日文这类大字符集建议直接设为2048x2048。因为动态图集虽然会自动扩容但扩容的本质是“重新打包整张图集”期间会有明显的顿卡。设大初始值可以减少扩容次数。m_AtlasPadding控制字形之间的间距。这个值太小时SDF计算会产生字形边缘的互相污染表现为边缘发虚或出现黑色描边。6像素对我来说是经验值如果字体本身比较复杂可以加到8。3.3 三种加载方式的对比与使用建议在实际开发中很多人会在“用new Font(path)”“用Font.CreateDynamicFontFromOSFont”“用Resources.Load”这三种方式里纠结。我给个横向对比加载方式适用场景优势坑点new Font(string fontName)编辑器下快速测试一行代码无需路径打包后可靠性差依赖系统字体注册表new Font(string absolutePath)运行时加载外部字体支持任意路径可控性强某些平台上文件流可能被占用需先复制Font.CreateDynamicFontFromOSFont直接通过系统字体名生成动态字体无需关心文件路径对.otf支持不统一且在WebGL等平台失效我在生产环境里的选择是“先复制到persistentDataPath再加载”原因在于系统字体目录是系统保护的在部分Windows设备上UWP打包的应用无法直接读取C:\Windows\Fonts文件访问会被拒绝。复制到应用自己的数据目录后后续的字节读取不会遇到权限问题而且在加载失败时还方便排查。4. 踩坑记录动态字体在真实项目中的那些坑代码能跑通只是第一步真正把它用进项目你会遇到几个文档里没有明确写的坑。我把它们列出来每一个都是我实际踩过之后的血泪教训。4.1 中文和日文等大字符集的内存暴涨用动态TMP做中文本地化是最容易出问题的场景。中文字符集有两万多个常用字当动态图集不断扩容时最直接的影响是显存占用和重组开销。假设你的图集是2048x2048单张SDF纹理大约16MB显存而TMP的动态字体在扩容时旧图集并不会立刻释放它会等待GPU引用计数归零后才回收。如果你在一个列表里疯狂切换文本一不留神就会看到显存飙到好几百MB。我的建议是如果目标语言是中文或日文尽量不要用纯动态方案。更好的做法是混合方案——用预烘焙的静态TMP_FontAsset保证最常用的3500个汉字再叠加一个动态TMP_FontAsset处理生僻字。这样的话日常UI走静态图集只有遇到生僻字才触发动态扩容性能开销会小很多。4.2 动态图集扩容时的卡顿与规避TMP动态字体把新字符加入图集的过程是在主线程上同步完成的。这意味着当玩家第一次输入或显示某些字符时画面会卡一帧。如果是剧情对话玩家可能感觉不明显但如果是打字机效果、实时排行榜这类高频刷新场景卡顿就会被放大。规避方法有两种第一种是预热在游戏加载界面时把可能用到的高频字符用TMP_Text.ForceMeshUpdate()强制渲染一遍让它们提前进入图集第二种是分离线程用FontEngine.RequestCharactersInTexture在后台子线程提交字符纹理请求主线程只做状态查询。第二种方式实现复杂但确实能彻底消除卡顿。4.3 字体回退链Fallback的正确配置你的游戏不可能只用一个字体就支持所有语言。比如在东亚语言环境里系统默认的日文字体可能不包含某些生僻汉字。TMP的Fallback机制允许一个TMP文本组件同时引用多个TMP_FontAsset当主字体缺失字符时自动去次字体里寻找。动态字体下的Fallback有个特殊之处当主字体找不到字符时TMP会去Fallback字体找如果Fallback字体同样是动态字体它会触发Fallback字体自己的图集扩容。这里有一个隐藏的顺序问题主字体的动态图集和Fallback字体的图集相互独立也就是说同一个字符在主字体和Fallback字体里各占用一份图集空间。这个内存开销虽然是可接受的但别忘了在内存统计逻辑里加上这两个图集的大小。4.4 字体文件被占用的Windows陷阱在Windows上如果你在游戏运行同时用系统字体预览工具打开了某个字体文件此时再去File.Copy这个文件会抛出IOException: The process cannot access the file because it is being used by another process。这个问题在开发机上出现的概率很高尤其是美术同学开着字体预览器而你正在跑游戏的时候。稳健的处理方式是采用Windows的卷影复制机制但这对于游戏项目来说太重了。我建议直接把复制失败当成一种常规状况来处理先在try-catch里尝试复制失败就降级为直接new Font(sourceFontPath)。这样至少不会让整个字体加载流程崩溃最多是字体加载方式发生变化而文件权限问题并不会导致加载失败因为new Font(path)内部使用的是文件映射而非独占读取。5. 平台差异与工程集成实践5.1 桌面平台与移动平台的差异化处理这个方案在桌面平台的适配成本最低Windows和macOS都有完整的系统字体体系。但到了移动平台情况就变了Android的字体文件路径在不同厂商ROM上差异很大iOS则因为沙盒机制根本无法直接访问系统字体文件。在Android上你可以尝试读取/system/fonts目录需要在Manifest里申请READ_EXTERNAL_STORAGE权限但很多厂商的ROM会在这个目录上做限制只允许系统App读取。更可靠的方案是Unity的Font.CreateDynamicFontFromOSFont(Roboto, 16)在Android上通常能命中系统默认字体但这个API只支持获取系统默认字体不支持“获取所有字体列表”。所以在Android上如果你确实需要支持某种非默认字体最好还是老老实实把字体文件打进包里不要寄希望于运行时从系统抠。iOS上Unity的底层会通过CoreText接口向系统请求字体但开发者能拿到的仍然只是字体名称比如“.SF UI Text”拿不到实际的字体文件路径。因此我的结论是这个方案的最佳场景是PC游戏移动平台只适合作为兜底方案。5.2 资源生命周期与内存管理动态TMP_FontAsset和普通Asset一样需要你手动管理生命周期。特别是当你频繁切换语言包时如果每次都创建新的TMP_FontAsset而不释放旧的内存泄漏几乎是必然的。我的习惯是维护一个全局字典private static Dictionarystring, TMP_FontAsset _fontCache new Dictionarystring, TMP_FontAsset();在创建之前先查缓存同一个字体只创建一次。切换语言包时把旧字体从字典中移除并调用Resources.UnloadAsset或Destroy释放。TMP_FontAsset继承了ScriptableObject直接使用Destroy()是无效的——ScriptableObject必须用Resources.UnloadAsset或者设为null等待GC回收。5.3 与Addressables或AssetBundle的搭配使用如果你项目里用了Addressables来管理资源那么动态TMP_FontAsset和这套方案是完全兼容的。你可以先在编辑器阶段把字体文件做成Addressable资源运行时加载字体文件再用运行时创建TMP_FontAsset。不过要注意一点运行时创建的TMP_FontAsset并不是Addressable资源它只是内存中的对象所以不能走Addressable的引用计数来管理需要自己按上文的内存管理策略来处理。对我来说这个项目带来的最大收益并不是“省去了打包字体的麻烦”而是让我深入理解了TMP的动态字体渲染机制。以前遇到字体不显示、字形崩坏这类问题大多数时候只能靠猜测和堆参数。现在能清楚地知道它在图集层面做了什么、扩容是在什么时机触发的、Fallback的查找顺序怎么运转排查问题的时候就靠谱多了。如果你也打算在自己的项目里引入类似能力建议从最小可用的环节开始先只做“获取系统字体名列表并在Editor里打印出来”跑通了再去碰运行时创建TMP_FontAsset。这个节奏会让你的调试成本低很多。本文还有配套的精品资源点击获取