简介UnityNativeOSFont是一套面向Unity开发者的开源插件方案核心解决TextMeshPro无法直接调用操作系统本地字体的问题。它通过C#脚本在运行时获取系统字体列表并转换为TMP可用的动态字体资源适合需要动态切换字体、本地化显示或减少内置字体体积的游戏与应用。压缩包共107个文件以ShaderLab着色器、C#源码、TMP预设和示例场景为主其中13个shader与4个cginc用于优化文字抗锯齿、描边等渲染效果另含说明文档与演示字体整体仅1.33MB。该资源已被1046人学习使用可作为Unity中实现系统字体动态加载的参考资料。借助配套的Shader自定义能力开发者能够针对不同平台字体差异做兼容处理无需预装字体文件即可提升文本展示质量对电子书、教育软件等强文本渲染需求的项目尤其实用。1. 把系统字体变成 TMP 动态字体为什么值得折腾这件事做 Unity 项目尤其是做多语言 UI 或中文界面时TextMeshPro 的字体资产是个绕不开的坎一张 8192 的 SDF 图集动辄几十 MB 内存做一个“完整版”中文字体资产能直接把包体顶爆。你当然可以只做常用字库但遇到用户输入姓名、聊天内容、外部接口返回的文本时缺字就是肉眼可见的翻车。UnityNativeOSFont 这类方案换个思路不打包字体而是在运行时直接读取操作系统已经安装的字体文件把它喂给 TMP动态生成 SDF 图集并按需填充字形。适合做工具类应用、编辑器扩展、自助终端这类对 UI 字体有强动态需求的场景普通手游如果在意首包体积也可以拿来存一份兜底。这篇文章把读取、生成、替换、避坑整套流程拆开讲。2. 先搞明白 TMP 为什么“吃”不了系统字体动态 SDF 的底层逻辑2.1 TMP 默认流程要求静态字体资产运行时只认 GUIDTMP 的项目里所有 TextMeshPro 组件最终依赖的是一个 TMP_FontAsset。这个资产内部包含图集贴图、字形索引、材质参数和 shader 引用。默认工作流下你通过 TMP 的字体创建窗口导入 ttf/otf勾选“Custom Characters”或“Atlas Population Mode”编辑器会调用 SDF 烘焙逻辑生成一个静态资产存进 Assets 目录。运行时 UI 组件直接引用这个资产文件的 GUID整个过程和操作系统字体目录没有任何关系。而系统字体在 Windows 上住在一个被系统锁定的路径下通常是通过环境变量 WINDIR 指向的 Fonts 目录运行时扫描这些文件没有任何资产 GUID 可言。TMP 要接受它们唯一的办法是提供一个“伪造”的 TMP_FontAsset它包含必要的 atlask 图和材质但不依赖磁盘文件而是把字形数据存放在运行时的 Texture2D 里。这就是“动态 SDF”的本质它模拟了 TMP 编辑器烘焙 SDF 的流程只是在播放模式里执行。2.2 系统字体不是“一个文件”先分清字体族名和物理文件Windows 的字体目录里一个字体族往往对应多个文件。微软雅黑有 msyh.ttc、msyhbd.ttc宋体有 simsun.ttc等线字体常见为 Deng.ttf。更麻烦的是 .ttc 文件里包含多个字体副本带不同的字体族名而物理文件名和字体族名并不总是同名的。Unity 自己有两条相关 API一是 Font.GetOSInstalledFontNames() 返回系统字体族名列表二是 Font.CreateDynamicFontFromOSFont(fontName, size) 可以直接用字体族名创建动态字体。但这两条 API 在 TMP 场景下有明显的边界Font.CreateDynamicFontFromOSFont 得到的是一个 UnityEngine.Font它是 UGUI 体系的东西不能直接喂给 TMP_FontAsset.CreateFontAsset。TMP 需要拿到的是字体文件路径或二进制数据然后走自己的导入管线。正确做法是扫描目录拿到候选文件再逐一尝试用 TMP 支持的路径生成字体资产把失败的剔除这个“探查-回退”的过程绕不开。2.3 从字体族名解析到物理路径用映射表 文件探测代替单一 API我一般先用一个静态类封装系统字体探测逻辑核心思路是三步第一步取 WINDIR 环境变量拼出 Fonts 目录第二步读取目录下的 .ttf/.ttc/.otf 文件构造一个“物理路径-显示名”的粗略映射第三步对目标字体名做模糊匹配优先精确匹配匹配不到就挨个尝试从文件创建字体并读取其内部字体名。public static string ResolveFontFilePath(string familyName) { string fontsDir Path.Combine(Environment.GetEnvironmentVariable(WINDIR), Fonts); if (!Directory.Exists(fontsDir)) return null; foreach (string pattern in new[] { *.ttf, *.ttc, *.otf }) { foreach (string file in Directory.GetFiles(fontsDir, pattern)) { string name Path.GetFileNameWithoutExtension(file); if (name.Equals(familyName, StringComparison.OrdinalIgnoreCase)) return file; // 对 .ttc 做二次探测用 Unity 的 Font 创建试读 Font font new Font(file); string internalName font.name; font.Destroy(); if (internalName.Equals(familyName, StringComparison.OrdinalIgnoreCase)) return file; } } return null; }参数说明pattern 数组按顺序覆盖 Windows 中文版常见的 ttf、ttc、otf 三类格式匹配方式先做文件名近似匹配效率高再借助 UnityEngine.Font.name 读取 TTC 内部名字做兜底。这里有一个细节new Font(file) 会真实加载字体到内存用完后要主动调用 Object.Destroy 释放否则每解析一次就多一份字体引用累积多了会拖慢编辑器或运行时性能。另一个容易忽略的点是 .ttc 文件里的字体族名可能包含空格和加号完整解析需要把 Font.name 再做一次 Trim 和大小写归一化后再比较。3. 从字体文件到动态 TMP 资产完整复现路径3.1 确定 TMP 版本与 API 入口不同 TMP 版本里 CreateFontAsset 的签名不完全一致Unity 2019 到 Unity 2022 内置的 TMP 3.x 版本基本都支持 TMP_FontAsset.CreateFontAsset(Font font) 这个重载。传入的字体可以是任意路径创建的 UnityEngine.Font 对象TMP 内部会用 FreeType 解析字形并生成 SDF 图集。需要注意的版本分界点是旧版 TMP例如 1.x 的导入包里这个重载可能存在参数缺失的问题命名空间也略有不同一个比较省事的做法是在代码里直接引用 Unity.TextMeshPro 命名空间再通过反射检查 API 是否存在避免某些项目里新旧版本混用导致编译失败。3.2 生成动态 SDF 字体资产的代码骨架拿到字体文件路径后接下来的核心动作是创建 UnityEngine.Font、设置动态参数、再转成 TMP_FontAsset。创建动态 SDF 资产有两个方向一是直接走 TMP_FontAsset.CreateFontAsset让它自动配置 atlas二是先创建 TMP_FontAsset 再手动覆盖 texture 参数。我常用第一种写法配合后续参数调整public static TMP_FontAsset CreateDynamicFontAsset(string fontPath, int samplingSize 64, int atlasWidth 1024, int atlasHeight 1024) { Font osFont new Font(fontPath); if (osFont null) return null; TMP_FontAsset asset TMP_FontAsset.CreateFontAsset(osFont); asset.atlasWidth atlasWidth; asset.atlasHeight atlasHeight; asset.m_AtlasPadding 6; asset.m_AtlasRenderMode GlyphRenderMode.SDFAA; asset.m_SamplingPointSize samplingSize; asset.m_IsMultiAtlasTexturesEnabled true; asset.m_ClearDynamicDataOnBuild true; return asset; }逻辑说明CreateFontAsset 会立刻用 FreeType 格式解析生成一份默认 atlas即使里面还没填充任何字形atlasWidth 和 atlasHeight 控制图集物理尺寸动态字体场景下建议保留 1024 或 2048尺寸太小频繁扩容会触发重复校验拖慢帧率m_AtlasPadding 是字形之间的间隔像素至少保留 4 到 6太小会出现 SDF 采样互相渗透m_AtlasRenderMode 选 SDFAA 对应 TMP 默认的 SDF 渲染路径采样点 size 决定字形边缘清晰度的上限。这里把 m_ClearDynamicDataOnBuild 打开构建时清掉运行时数据只保留动态字体描述包体里不会残留巨大的图集信息。3.3 与 TMP 组件的关联替换 TextMeshProUGUI.font动态字体资产创建完还不能直接生效。每个 TextMeshProUGUI 组件里都有 font 属性赋值后组件重绘会按需请求字形。批量赋值时需要注意 Canvas 的 rebuild 时序我一般等一帧再赋值以避免同一帧里字体切换与顶点重建穿插执行产生杂散的空白帧代码大致长这样IEnumerator SwapAllDynamicFonts(TMP_FontAsset newFont) { TMP_Text[] allTexts FindObjectsByTypeTMP_Text(FindObjectsSortMode.None); foreach (var txt in allTexts) { txt.font newFont; txt.ForceMeshUpdate(); } yield return null; }这里有两个值得说道的参数点ForceMeshUpdate 会立刻触发当前文本的字形请求而不是等 Canvas 下一次脏标记刷新这在同一帧需要截屏或者需要立刻测量文本尺寸时用得上FindObjectsByType 是 Unity 2022 后的推荐写法老项目里可以替换成 FindObjectsOfType。如果场景里文本量很大一次性刷掉所有组件会让主线程卡顿推荐分帧处理每次只刷 20 到 30 个组件。3.4 ShaderLab 侧的配合确认动态 SDF 用的 shader 变体在打包后仍然存在动态字体存在的意义是运行时生成图集但如果 shader 变体在打包时被裁剪效果等同于黑匣子UI 上显示一片粉红或空白。TMP 默认的 SDF shader 是 ShaderLab 写成的一个大文件表面上是单个 shader内部根据 keyword 拆出多个变体。动态 SDF 通常走的是 SDFAA 与 Dynamic 相关的 keyword 组合这些变体的 keyword 是从 C# 侧通过 Material.EnableKeyword 开启的。正因为是运行时开启的 keywordUnity 的 shader 变体收集器无法在打包阶段静态地确认它被使用于是默认情况下不会包含进去。需要手动处理把 TMP 的默认 SDF shader 添加到 Project Settings Graphics 的 Always Included Shaders 列表里。更精确的做法是复制一份 TMP 的 SDF 着色器或者直接引用 TMP 包内自带的那个打开后给变体勾选 Dynamic SDF、Multi Atlas 等需要的 keyword保存后添加到 Always Included。这两个操作在编辑器里看似无关但要在设备上真机看到动态字体渲染缺一不可。所谓“动态 SDF 不出字”的案例一半以上衰在 shader 变体上而不是字体生成上。4. 按需加载和批量替换把动态字体玩出工程感4.1 字体映射表不止是“找到字体”还要处理缺失字体每个 Windows 系统预装字体并不完全一致尤其是各种精简版系统会砍掉某些字体。团队内开发机几乎都是完整系统字体测试机上却可能缺字体这算得上最常见的运行时字体翻车原因。我习惯在启动时维护一张 Mapping 表记录“目标字体族名 → 文件路径”如果路径不存在就回退到另一个确认存在的替代字体比如缺微软雅黑就自动退回宋体这张表同时承担了语义化命名的职责UI 侧永远用“Default”“Title”“Mono”这类抽象字体名避免把具体 Windows 字体名写死在场景里。抽象层转译的好处是以后换字体只需改配置。4.2 批量替换组件字体引用的可执行步骤拿到动态 TMP_FontAsset 之后完整替换流程分三步走第一步扫描场景里所有 TMP 文本组件第二步按 prefab 上标记的“字体槽位”即对抽象字体名的引用分组第三步把每个槽位映射到对应字体上。第二步是关键如果没有槽位概念统一替换成同一个字体会让标题、正文、数字字体混在一起反而丢失层次。实际项目里可以直接用 TMP 组件挂在的 GameObject 名字或 Tag 做简单分组自动化程度更高的是在 TextMeshProUGUI 上挂一个自研 FontSlot 组件用 SerializeField 存一个字符串槽位名配合编辑器脚本批量配置上线后就能随时排查哪个文本用的哪个抽象字体。4.3 动态字体资产不该全局只有一个动态 SDF 图集是按需填充的填进去的字形不会自动移除所以你的“动态字体”如果被同时塞进 UI 和 3D 场景里字形集合会迅速变大。在内存敏感的平台上正确做法是给 TextMeshProUGUI 和 TextMeshPro 分别创建各自的动态字体实例两者互不干扰。也可以按语言拆成中文字体、英文数字字体、特殊符号字体三层中文字体只维护中文字形。层数越多图集总量越小代价是组件切换字体的频率变高需要根据项目实际量级权衡。还有一个问题是运行时动态生成的 TMP_FontAsset 默认 TextAsset 形式存在不保存到磁盘就不能在下次启动时直接加载。每次冷启动都要重新生成一份耗时约几十到几百毫秒不等这个时耗看着不多但用户首帧就会看到 UI 字体闪烁。解决方案是首屏做一个纯色 Loading 遮罩把字体生成提前到场景激活前或者做一个轻量的字体资产缓存生成后把 atlas 纹理的字节数据序列化到持久化目录下次启动 LoadImage 出具图集。TMP_FontAsset 的创建参数变化会导致缓存失效所以在缓存 key 里需要带上字体路径、采样点大小、atlas 尺寸这几个要素。5. 避坑重灾区字体文件、ShaderLab 变体与 Populate 时序5.1 字体文件被系统锁定导致加载失败现象运行到字体加载时抛 IOException提示文件被占用尤其在 Windows 10/11 上频繁出现。 原因Windows 字体目录里的字体文件普遍被系统字体服务锁定直接 File.ReadAllBytes 会触发共享冲突。Unity 的 new Font(path) 走底层 API不受这个限制但如果先读了字节再构造就会踩坑。 解决绕开字节读取直接把路径传给 Font 构造函数或者先用 FileStream 加 FileShare.ReadWrite 打开再读能正常处理带锁定属性的系统字体文件。5.2 TTC 字体内部多个字体族导致名字错乱现象明明找的是“微软雅黑 Bold”加载了一个 .ttc 文件生成出的 TMP 字体资产名字完全不对。 原因.ttc 是字体集合一个文件包含多个字体而 TMP 的 CreateFontAsset 只取文件里的主字体。文件路径相同不代表字体就是目标那个。 解决创建前用 Font 构造加载并读取内部字体名确认或者直接维护一份精确的“字体族名 → 文件路径 内部索引”映射表微软雅黑这种常用字体提前验证过的路径直接硬编码不做运行时猜测。5.3 动态字体首次填充字形时界面闪一下空白现象UI 上第一次出现某个生僻字时该字符区域空白一帧后才显示。 原因TMP 的 glyph 填充是异步且延后的网格重建和字形数据生成不在同一个时序里首次遇生僻字会先在当前图集上寻找找不到再触发扩容/重建期间顶点数据尚未更新。 解决提前把需要渲染的文本预热。做法是把启动阶段的全部 UI 文本收集起来依次设置到某个隐藏的 TextMeshProUGUI 上触发一次字形解析等图集填充完成再正式激活 UI。简单可靠也有一些“字体预加载表”的方案把常用字和生僻字列表直接灌进一个专用文本组件效果一样。5.4 Shader 变体被裁剪导致动态字体显示为洋红色现象编辑器里一切正常打包到真机后所有动态字体变成全屏洋红或直接消失。 原因动态字体使用的 shader 各自依赖不同的 keyword 变体而实现方式是在运行时通过 Material.EnableKeyword 切换的对打包阶段不可见变体被剥离。 解决打开 Project Settings Graphics把 TMP 默认 SDF 相关 shader 加到 Always Included Shaders。如果用了自研 ShaderLab 变体还要保证变体关键词与 TMP 的 C# 代码中 EnableKeyword 字符串完全对应多一个空格都会翻车。5.5 动态字体图集生成后不能调整参数现象运行时改了 atlas 尺寸或 padding再次调用 CreateFontAsset 发现新参数没生效图集依旧是旧尺寸。 原因TMP_FontAsset 内部维护了一份生成参数缓存同名字体内再次生成时会优先复用旧数据。 解决每次修改参数后必须重新创建一个新 TMP_FontAsset 实例先释放旧实例再执行 CreateFontAsset。切记不要图省事反复在同一个对象上改参数动态字体是“一次生成、终身使用”的逻辑。6. 验证技巧确认动态替换真的走了 SDF 渲染路径这里说一个我常用的验证手段替换完后在场景里放一个 Debug 文本显示当前字体资产的名称、图集大小和一个“实时填充率”指标。填充率是图集已被字形占据像素除总像素这个数值每次弹出新字符后都会变化能直观地看出动态字体是否在持续工作。要正确计算它需要读取 TMP_FontAsset.atlasTexture 的像素数据统计其中非透明像素的比例这一步要用 Texture2D.GetRawTextureData 异步读取避免阻塞主线程。如果填充率一直停在某个值不变化说明字形请求根本没触发问题多半出在字体赋值的绑定关系上。另一个不写代码的验证方法更简单在 Profiler 的 Memory 分类里观察 Texture2D 内存是否随时间增长动态 SDF 工作正常的项目里纹理内存在用户输入新字符时会有明显台阶式上涨这是字形填充的直观证据。我自己习惯在启动阶段留一个 Debug 开关把这个验证逻辑挂在 F12 键上屏幕上同时显示字体名、atlas 尺寸、填充率、shader 名四项信息实测下来排错效率极高。从那以后我每次接动态字体需求都强制团队先跑一遍这个调试面板确认才能提测配合运行时按需生成、重点字表预热的思路再没出现过上线后 UI 缺字这种事。希望这套路径能帮你在接入动态字体时把路走得更顺畅。本文还有配套的精品资源点击获取