简介:UnityNativeOSFont 是一套面向 Unity 开发者的开源工具,用于在运行时获取操作系统本地字体并接入 TextMeshPro 动态字体渲染,解决 TMP 默认字体库无法覆盖各平台系统字体、需手动导入字体文件的问题。它通过 C# 脚本读取系统字体列表并转换为 Unity 可识别格式,配合 ShaderLab 自定义着色器优化抗锯齿、描边等渲染效果,适用于电子书、教育软件及需要本地化字体支持的项目。资源包共 107 个文件,约 1.33MB,包含 13 个 shader、4 个 cginc 等着色器资源,23 个 asset 与 2 个 mat 等配置素材,以及 2 个 cs 脚本、示例场景和说明文档,结构完整便于直接导入使用。目前已有 1045 人学习下载。读者可借此掌握系统字体枚举、TMP 动态字体应用与跨平台兼容处理思路,并参考着色器实现优化文本显示质量。
1. UnityNativeOSFont:把系统字体变成 TMP 动态字体的那条路
做 Unity 项目时,UI 里出现生僻字、少数民族文字、日韩越混排,甚至用户自定义昵称里带 emoji 组合,TextMeshPro 的静态字体图集就开始翻车——方块、豆腐块、缺字警告刷满 Console。常规解法是往 Font Asset 里塞几千个字符重新烘焙,包体直接膨胀几十兆,而且用户输入什么你根本预判不了。UnityNativeOSFont 这个方向要解决的就是这件事:不再预烘焙,而是在运行时直接向操作系统要字体文件,把它喂给 TMP 的动态字体系统,按需生成字形。它适合做工具类 App、社交聊天、输入框、多语言阅读器这类「字符集不可穷举」的项目,也适合被包体优化逼到墙角的团队。核心链路只有三步:拿到系统字体路径、读成字节流、交给 TMP_FontAsset 动态渲染。听起来简单,坑全在平台差异和 TMP 的内部机制上。
2. 系统字体从哪来:三平台的取字路径与选型理由
2.1 为什么不用打包内置字体
先说选型。很多人第一反应是把一个覆盖全 Unicode 的字体(比如思源黑体全量版)打进 StreamingAssets,省事。但全量 CJK 字体动辄 15~20MB,加上 TMP 图集,包体压力很大;更麻烦的是系统级 emoji、部分小语种字形,内置字体未必覆盖,用户一输入还是缺字。动态从系统取字体的价值在于:字形覆盖跟着操作系统走,用户系统里能显示的字,你的 App 基本也能显示,包体只增加读取逻辑那点代码。
代价也要讲清楚:系统字体路径因平台、系统版本、厂商定制而不同,读取可能失败,字体授权也因系统而异(自用渲染通常没问题,但别把系统字体文件再分发出去)。所以工程上一般做成「系统字体优先,失败回退内置字体」的双保险。
2.2 三个平台的字体目录
不同平台拿字体的方式差别很大,先建立一张对照表,后面代码都围绕它展开。
| 平台 | 典型字体目录 | 常见字体文件 | 读取方式 |
|---|---|---|---|
| Windows | C:/Windows/Fonts | msyh.ttc、simhei.ttf、arial.ttf | File.ReadAllBytes |
| macOS | /System/Library/Fonts、/Library/Fonts | PingFang.ttc、Helvetica.ttc | File.ReadAllBytes |
| Android | /system/fonts | NotoSansCJK-Regular.ttc、DroidSansFallback.ttf | File.ReadAllBytes(需权限范围内) |
| iOS | /System/Library/Fonts、/System/Library/Fonts/Core | PingFang.ttc、Helvetica.ttc | 部分路径受沙盒限制,优先用系统 API |
注意.ttc是字体集合(TrueType Collection),一个文件里打包了多个字重或字形集。TMP 的Font.CreateFontAsset对 ttc 的支持要看 Unity 版本和 FreeType 版本,很多情况下直接读 ttc 会失败或只取到第一个 face。稳妥做法是优先找.ttf/.otf,找不到再尝试 ttc 并做好异常兜底。
2.3 用代码枚举并挑选字体
下面这段是跨平台枚举候选字体的最小实现,思路是「按优先级列表逐个探测,命中即返回」。
using System.IO; using UnityEngine; public static class OSFontLocator { // 按优先级排列的候选路径,前面的命中就不再看后面 static readonly string[] WinCandidates = { "C:/Windows/Fonts/msyh.ttc", // 微软雅黑,中文覆盖好 "C:/Windows/Fonts/simhei.ttf", // 黑体,ttf 更稳 "C:/Windows/Fonts/arial.ttf" // 兜底拉丁 }; static readonly string[] MacCandidates = { "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Helvetica.ttc" }; static readonly string[] AndroidCandidates = { "/system/fonts/NotoSansCJK-Regular.ttc", "/system/fonts/DroidSansFallback.ttf" }; public static string Find() { string[] list; #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN list = WinCandidates; #elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX list = MacCandidates; #elif UNITY_ANDROID list = AndroidCandidates; #else list = new string[0]; #endif foreach (var p in list) { if (File.Exists(p)) return p; // 命中即返回 } return null; // 全部失败,交给上层回退内置字体 } }逻辑说明:Find()只负责「找到第一个存在的字体文件路径」,不做加载,职责单一,方便单测。参数上,候选数组的顺序就是优先级,中文项目把雅黑/Noto 放前面,纯英文项目可以把 Arial/Helvetica 提前。返回null是明确信号,调用方必须处理,不能假设一定有系统字体。
提示:Android 上
/system/fonts一般可读,但个别定制 ROM 会收紧权限,File.Exists返回 false 时不要慌,走回退逻辑即可。iOS 沙盒对/System的访问限制更严,真机上更推荐用Font.CreateDynamicFontFromOSFont拿到系统字体名,再配合 TMP 的 fallback 机制,而不是硬读文件路径。
3. 把字体字节流喂给 TMP:动态 FontAsset 的创建与参数
3.1 TMP 动态字体的工作方式
TMP 的TMP_FontAsset有两种模式:静态(图集预烘焙,字符固定)和动态(运行时按需把字形渲染进图集)。动态模式的关键是atlasPopulationMode设为Dynamic,并保证TMP_Settings里开启了动态字体支持。动态图集有容量上限,默认 1024x1024,字符一多会触发图集扩容或重建,这就是为什么「字符集不可穷举」场景必须用动态——你不可能预知用户输入什么。
从系统字体字节流创建 FontAsset,走的是TMP_FontAsset.CreateFontAsset的重载,它内部用 FreeType 解析字体数据。这里有个容易忽略的点:创建出来的 FontAsset 是运行时对象,不落盘,切场景时如果不DontDestroyOnLoad或放进常驻管理器,会被 GC 回收,导致后续文本变方块。
3.2 从字节流创建动态 FontAsset
using System.IO; using TMPro; using UnityEngine; public static class OSFontAssetBuilder { public static TMP_FontAsset Build(string path) { if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; byte[] data = File.ReadAllBytes(path); // 一次性读入内存 if (data == null || data.Length == 0) return null; // 关键参数:采样点大小、图集尺寸、模式 var font = TMP_FontAsset.CreateFontAsset( data, // 字体字节流 90, // samplingPointSize:采样点,影响清晰度 9, // atlasPadding:字形间距,防粘连 UnityEngine.TextCore.LowLevel.GlyphRenderMode.SDFAA, 1024, // atlasWidth 1024, // atlasHeight AtlasPopulationMode.Dynamic, // 动态模式 true // enableMultiAtlasSupport:允许扩容多图集 ); if (font != null) { font.name = "OSFont_" + Path.GetFileName(path); Object.DontDestroyOnLoad(font); // 防止被回收 } return font; } }逻辑说明:CreateFontAsset的第一个参数接受byte[],这是从系统字体文件读出来的原始数据。samplingPointSize决定 SDF 采样的精细度,90 是常用值,太小会糊,太大图集消耗快。atlasPadding给字形留边,防止相邻字形 SDF 溢出粘连,9 是经验值。GlyphRenderMode.SDFAA是带抗锯齿的 SDF,UI 场景通用。enableMultiAtlasSupport设为 true 后,单张图集满了会自动开新图集,这是动态字体不爆的关键开关。
参数怎么调,给一张对照:
| 参数 | 常用值 | 调大后果 | 调小后果 |
|---|---|---|---|
| samplingPointSize | 60~90 | 更清晰,图集消耗快 | 发虚,小字号糊 |
| atlasPadding | 5~9 | 更安全,浪费空间 | 字形边缘可能粘连 |
| atlasWidth/Height | 1024 | 单图集容量大,内存高 | 频繁扩容,性能抖动 |
| enableMultiAtlasSupport | true | 不爆图集,内存上限高 | 超限后新字渲染失败 |
3.3 挂到 TMP 组件并处理回退
创建好 FontAsset 后,要把它赋给TMP_Text.font,同时把它加进fallbackFontAssetTable,这样主字体缺字时能自动切到系统字体。
using TMPro; using UnityEngine; public class OSFontApplier : MonoBehaviour { public TMP_Text target; // 要应用的目标文本 public TMP_FontAsset builtin; // 内置兜底字体 void Start() { string path = OSFontLocator.Find(); var osFont = OSFontAssetBuilder.Build(path); if (osFont != null) { target.font = osFont; // 把内置字体作为回退,系统字体缺字时兜底 osFont.fallbackFontAssetTable.Add(builtin); } else { target.font = builtin; // 系统字体失败,直接用内置 } } }逻辑说明:fallbackFontAssetTable是 TMP 的缺字回退链,主字体找不到字形时会依次查回退表。把内置字体挂上去,等于给系统字体上了保险。注意回退表是单向的,别写成循环引用(A 回退 B,B 又回退 A),否则 TMP 查字会死循环。
注意:动态 FontAsset 在运行时创建后,如果多个 TMP_Text 共用同一个实例,图集是共享的,这通常是好事(省内存)。但如果你给每个文本都
CreateFontAsset一次,就会创建多份图集,内存直接翻倍。正确做法是全局只建一份,缓存起来复用。
4. 避坑与排查:系统字体动态化的 5 个血泪现场
4.1 现象:真机上文字全变方块,编辑器正常
原因:编辑器在 Windows/macOS 上能读到系统字体,真机(尤其 Android 定制 ROM、iOS 沙盒)路径不存在或权限不足,Find()返回 null,代码没走回退,target.font被赋成 null。
解决:Build()返回 null 时必须显式回退到内置字体,别让font保持 null。加一行日志把实际路径打出来,真机连 Logcat/Xcode 看,比猜快得多。
4.2 现象:切场景后原本正常的文本变方块
原因:运行时创建的 FontAsset 没有DontDestroyOnLoad,切场景时被卸载,TMP_Text 引用的对象失效。
解决:创建后立刻DontDestroyOnLoad(font),或者放进一个常驻的字体管理器单例里持有引用。别指望 TMP 帮你保命。
4.3 现象:输入大量不同字符后,新字渲染不出来,Console 报图集满
原因:动态图集容量到顶,enableMultiAtlasSupport没开,或者开了但atlasWidth/Height太小,扩容次数过多触发限制。
解决:创建时把enableMultiAtlasSupport设为 true,图集起步 1024。如果项目字符量极大(比如聊天记录滚动),考虑定期清理不用的字形,或对历史文本用静态快照。
4.4 现象:读.ttc文件抛异常或只显示部分字重
原因:TMP/FreeType 对 TrueType Collection 的支持不完整,一个 ttc 里多个 face,默认可能只解析第一个,或者直接解析失败。
解决:优先选.ttf/.otf候选路径。只有 ttc 可用时,用 try-catch 包住CreateFontAsset,失败就走回退。别在 ttc 上死磕。
4.5 现象:字体加载瞬间卡顿,低端机掉帧明显
原因:File.ReadAllBytes同步读大字体文件(十几 MB),加上 FreeType 首次解析,全在主线程。
解决:把读取和创建放到异步线程或协程分帧,创建完成后回主线程赋值。字体文件读取本身可以用Task.Run,但CreateFontAsset涉及 Unity 对象,必须在主线程调用,所以拆成「异步读字节 → 主线程建 FontAsset」两段。
5. 进阶:缓存策略、字形预热与一套可复用的字体管理器
走到这里,基本链路已经通了。但要在真实项目里稳住,还得解决两件事:字体只建一次、常用字形提前预热。
先说缓存。全局维护一个Dictionary<string, TMP_FontAsset>,key 用字体路径,命中直接返回。这样无论多少个 UI 面板请求系统字体,底层只有一份 FontAsset 和图集。管理器大致长这样:
using System.Collections.Generic; using TMPro; using UnityEngine; public class FontManager : MonoBehaviour { static FontManager _inst; readonly Dictionary<string, TMP_FontAsset> _cache = new(); public static FontManager Instance { get { if (_inst == null) { var go = new GameObject("FontManager"); _inst = go.AddComponent<FontManager>(); DontDestroyOnLoad(go); // 常驻,跨场景不销毁 } return _inst; } } public TMP_FontAsset GetOSFont() { string path = OSFontLocator.Find(); if (string.IsNullOrEmpty(path)) return null; if (_cache.TryGetValue(path, out var cached)) return cached; // 命中缓存 var font = OSFontAssetBuilder.Build(path); if (font != null) _cache[path] = font; return font; } }逻辑说明:单例 + 字典缓存,保证同一路径只创建一次。DontDestroyOnLoad挂在管理器 GameObject 上,字体对象本身也随管理器常驻。参数上没什么可调的,重点是别在GetOSFont里做重复创建。
再说预热。动态字体第一次渲染某个字时才生成字形,会有一次小的卡顿。对已知的高频字符(比如数字、常用汉字前 500 个、项目固定文案),可以在加载时主动调用font.TryAddCharacters把它们提前烘进图集:
// 预热:把常用字符提前加入图集,避免首次渲染卡顿 string warmup = "0123456789abcdefghijklmnopqrstuvwxyz"; font.TryAddCharacters(warmup, out string missing); if (!string.IsNullOrEmpty(missing)) Debug.LogWarning($"预热缺失字符: {missing}");TryAddCharacters返回是否全部成功,missing输出没加进去的字符。预热字符集别贪多,几百个足够,加太多等于把静态烘焙的包体问题又搬回运行时内存。
最后给一个验证方法:在真机上跑一个「随机字符压力测试」,每秒往 TMP_Text 里塞一批随机 Unicode 字符,观察图集数量和帧率。图集数量稳定、帧率无尖刺,说明缓存和扩容策略都对了。我自己的习惯是,任何动态字体方案上线前,必在最低端的目标机型上跑这个压力测试,跑不过就不发。系统字体这条路能省包体、能覆盖生僻字,但它把不确定性从打包期挪到了运行期,测试必须补上。希望帮到你。
本文还有配套的精品资源,点击获取