资讯详情

Unity动态TMP字体:不导入字体文件,按需读取系统字体实现多语言适配

📅 2026/10/20 9:13:42 | 华诺云谱 👁 阅读
Unity动态TMP字体:不导入字体文件,按需读取系统字体实现多语言适配
简介UnityNativeOSFont是一款面向Unity开发者的开源插件解决TextMeshProTMP无法直接调用操作系统已安装字体的问题其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. 总体设计与核心链路三个模块如何把系统字体变成动态 TMP2.1 原生字体枚举层Windows 走系统 API其他平台如何兜底整个插件在结构上拆成三层原生插件层、C# 桥接层、TMP 生成层。原生层只负责一件事——找出当前操作系统上有哪些字体。在 Windows 上常见做法不是直接扫C:\Windows\Fonts目录而是调 DirectWrite 的字体集合接口因为目录下同一族字体可能有 TTC 集合、隐藏字体和别名直接按文件名枚举很难对应到 UI 上显示的字体家族名称。macOS 一般走 CoreTextLinux 则用 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 是比较稳妥的启动值想要更锐利的小字号可以提到 48GlyphRenderMode.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.csRuntime目录下放的是运行时真正会执行的 C# 代码Editor目录里的SystemFontCatalogGenerator用于在编辑器里扫描系统字体并把结果缓存成 ScriptableObjectPlugins目录下的原生动态库按照平台架构分开放置。导入时不要把整个源码仓库直接丢进 Assets而是在package.json里声明一个本地包用 Package Manager 的Add package from disk方式加载这样后续升级、移除、写依赖都更干净。3.2 编辑器脚本生成字体清单运行时枚举系统字体的开销比较大特别是在 Windows 上第一次扫描可能要数百毫秒。这套源码更推荐的做法是在编辑器阶段制作一份字体目录把字体家族名缓存进 ScriptableObject运行时直接引用这份清单只在用户需要下载字体时再访问原生层。[MenuItem(NativeOSFont/Refresh Catalog)] public static void RefreshCatalog() { var catalog AssetDatabase.LoadAssetAtPathNativeOSFontCatalog( Assets/Resources/FontCatalog.asset); if (catalog null) { catalog ScriptableObject.CreateInstanceNativeOSFontCatalog(); 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 FindObjectsByTypeTextMeshProUGUI(FindObjectsSortMode.None); foreach (var tmp in allTexts) { tmp.font fontAsset; tmp.fontSharedMaterial fontAsset.material; tmp.ForceMeshUpdate(); } }ForceMeshUpdate是强制网格重建不然旧文本的顶点缓存会一直保留到下一帧自然刷新。在列表或聊天界面里大量切换字体时不要一次性对几百个文本组件全部调用可以改成只对当前显示的组件更新滚动时再补充。回退顺序方面这套源码给了FontPriorityList的概念优先匹配 UI 语言对应的字体找不到就回退到 TMP 默认字体不会让整个界面直接崩掉。3.4 构建前要确认的三件事第一原生插件的架构必须和打包目标匹配Windows 打 x86_64Linux 打 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); IReadOnlyListstring AvailableFonts { get; } }系统字体实现走原生枚举Bundle 字体实现走 AssetBundle 内字体Remote 实现走 CDN 下载临时字体。UI 层只依赖IFontProvider真正决定“语言优先还是用户手动选择优先”的逻辑放在FontPriorityList里做成 ScriptableObject让策划在 Inspector 里配置优先级顺序就行。新增一种字体来源时不动 UI 代码这是这套方案最有价值的扩展点。5.2 验证方法怎么证明新字体策略没有坑改造完字体策略后光靠肉眼验证永远不够我会补一组自动化冒烟检查每个返回的字体名都要能在AvailableFonts里找到给同一段文本应用不同字体字符宽度差异要能被TextGenerator.GetPreferredValues捕获故意传入不存在的字体名回退要落到默认字体而不是抛异常图集扩容后内存不能持续增长。这些检查挂在编辑器菜单里每次改完原生层先跑一遍再合入。多语言 UI 最怕的就是字体静默回退用户看不出报错但排版全乱。希望这些整理能帮到你让系统字体和动态 TMP 的组合不再是一个玄学问题。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑