简介:UnityNativeOSFont 是一份面向 Unity 开发者的开源工程资源,重点解决 TextMeshPro 默认字体库无法覆盖各操作系统本地字体的问题,让项目在运行时动态读取系统字体并直接应用到 TMP 组件,适合需要本地化文本、电子书或教育类应用的中级开发者参考。压缩包共 107 个文件,约 1.33MB,以 asset 预设与 TMP 配置、shader 与 cginc 着色器代码、meta 导入信息为主,另含 C# 脚本、示例场景、说明文档与字体文件,ShaderLab 部分可用于抗锯齿、描边等渲染优化。资源已有 1046 人学习,读者可从中获得系统字体枚举与跨平台适配的完整实现思路、TMP 字体资产配置范例以及着色器处理参考,便于快速理解动态字体方案的目录组织与调用方式。
1. 从一次字体缺字的翻车说起:这个项目到底解决什么问题
做 Unity 项目做到多语言阶段,十有八九会撞上同一个坑:UI 里中文、日文、韩文、阿拉伯文混排,TextMeshPro 的动态字体图集里突然冒出几个方块,或者干脆整段文字不渲染。排查半天发现不是编码问题,是字体资源本身没覆盖那些字符,而项目里打包的字体文件又不可能穷举所有语种。我最早遇到这个问题时,第一反应是往工程里塞一个几十兆的全语种字体,结果包体直接膨胀,构建时间也跟着遭殃。后来才想明白,操作系统里本来就躺着现成的字体文件,为什么不直接问系统要?
UnityNativeOSFont 这个项目干的就是这件事:在运行时从操作系统字体目录里枚举可用字体,读取字体文件字节,再把它喂给 TextMeshPro 的动态字体资产,让 TMP 按需生成字形。它适合正在做多语言 UI、需要覆盖生僻字或小语种、又不想把字体全打进包体的团队。关键词 ShaderLab 在这里更多是渲染管线的背景语境——字体最终要落到 TMP 的材质与图集上,理解 ShaderLab 的贴图采样逻辑,能帮你判断字形模糊、图集溢出这类问题出在哪一层。
2. 架构拆解:字体从系统目录到 TMP 图集的完整链路
2.1 整体分层与模块职责
这个项目的结构不复杂,但分层很清晰,核心可以拆成三层。最底层是平台字体访问层,负责跟操作系统打交道,枚举字体、读取字体文件字节;中间是字体资产构建层,把拿到的字节流包装成 TMP 能识别的 FontAsset;最上层是运行时调度层,决定什么时候加载、加载哪个字体、怎么跟现有 TMP 组件绑定。
平台字体访问层是整条链路的起点,也是最容易出问题的地方。不同操作系统的字体存放位置、字体格式、枚举方式都不一样,Windows 下是C:\Windows\Fonts里的 ttf/otf,macOS 下是/System/Library/Fonts和/Library/Fonts,Linux 下则分散在/usr/share/fonts等目录。项目用条件编译把各平台的实现隔开,公共接口只暴露「列出字体」和「读取字体字节」两个能力,上层不关心底下是哪个系统。
字体资产构建层是技术含量最高的部分。TMP 的 FontAsset 本质上是一张图集加一份字符信息表,动态字体模式下,TMP 会在运行时把用到的字符渲染进图集。项目要做的,是把系统字体的字节流交给 TMP 的字体引擎,让它按需生成字形。这里涉及 TMP 的TMP_FontAsset.CreateFontAsset系列接口,以及字体图集的尺寸、填充、渲染模式等参数。
运行时调度层负责把前两层串起来,通常是一个管理器单例,持有已加载的字体资产缓存,对外提供「按字体名获取 FontAsset」的方法,UI 层拿到后赋给TMP_Text.font。
2.2 目录结构与关键文件
一个典型的工程目录大致是这样组织的,具体文件名可能因版本略有差异,但职责划分基本一致:
Assets/ NativeOSFont/ Runtime/ FontManager.cs // 运行时调度与缓存 IFontProvider.cs // 平台字体访问接口 WindowsFontProvider.cs // Windows 实现 MacFontProvider.cs // macOS 实现 LinuxFontProvider.cs // Linux 实现 FontAssetBuilder.cs // 字节流 -> TMP FontAsset Editor/ FontPreviewWindow.cs // 编辑器内预览系统字体 Samples/ FontSwitchDemo.cs // 切换字体的示例脚本IFontProvider是整个设计的抽象核心,它把平台差异挡在接口后面。FontManager是唯一对业务代码暴露的入口,内部维护一个Dictionary<string, TMP_FontAsset>缓存,避免同一字体重复构建。FontAssetBuilder单独抽出来,是因为字体资产构建的参数调整频率高,独立成类方便调参和替换。
2.3 平台字体访问的实现要点
平台层的核心是枚举和读取。以 Windows 为例,字体目录固定,遍历目录下的 ttf/otf 文件即可拿到字体名和路径。macOS 和 Linux 则需要处理多个目录,还要过滤掉非字体文件。下面是一段简化后的接口与 Windows 实现示意:
// 平台字体访问的统一接口 public interface IFontProvider { // 返回系统可用字体的名称列表 List<string> GetAvailableFontNames(); // 根据字体名读取字体文件原始字节 byte[] ReadFontBytes(string fontName); } // Windows 平台实现 public class WindowsFontProvider : IFontProvider { private const string FontDir = @"C:\Windows\Fonts"; public List<string> GetAvailableFontNames() { var result = new List<string>(); // 只取 ttf 和 otf,其他格式 TMP 不一定认 foreach (var file in Directory.GetFiles(FontDir, "*.*")) { var ext = Path.GetExtension(file).ToLower(); if (ext == ".ttf" || ext == ".otf") result.Add(Path.GetFileNameWithoutExtension(file)); } return result; } public byte[] ReadFontBytes(string fontName) { var path = Path.Combine(FontDir, fontName + ".ttf"); if (!File.Exists(path)) path = Path.Combine(FontDir, fontName + ".otf"); // 文件不存在时返回 null,由上层决定降级策略 return File.Exists(path) ? File.ReadAllBytes(path) : null; } }这段代码的关键点在两个地方。一是扩展名过滤,TMP 对字体格式有要求,ttc 这类集合字体直接读进来可能解析失败,所以只放行 ttf 和 otf。二是读取失败返回 null 而不是抛异常,因为系统字体目录里可能有权限受限或损坏的文件,抛异常会让整个枚举流程中断,返回 null 让上层有机会跳过并继续。
GetAvailableFontNames返回的是文件名去扩展名,这跟用户在系统字体设置里看到的显示名不一定一致,实际项目里常见做法是再读一次字体内部的 name 表拿显示名,但那需要额外的字体解析库,多数场景下用文件名够用。
2.4 字节流到 TMP FontAsset 的构建
拿到字节流之后,下一步是构建 FontAsset。TMP 提供了从字节数组创建字体资产的接口,核心是TMP_FontAsset.CreateFontAsset的重载版本。下面这段是构建逻辑的示意:
public static TMP_FontAsset BuildFromBytes(byte[] fontBytes, string assetName) { // 参数依次为:字体字节、采样点大小、图集宽高、图集填充、渲染模式 var fontAsset = TMP_FontAsset.CreateFontAsset( fontBytes, 90, // 采样点大小,影响字形精度 9, // 图集 padding,防止相邻字形采样溢出 GlyphRenderMode.SDFAA, // SDF 渲染,支持描边和缩放 1024, 1024, // 图集尺寸 AtlasPopulationMode.Dynamic, // 动态模式,按需生成字形 true // 启用多图集,图集满了自动开新图集 ); fontAsset.name = assetName; return fontAsset; }参数里最值得说的是采样点大小和图集尺寸。采样点大小决定字形渲染的精细度,90 是个比较稳妥的默认值,太小会导致小字号发虚,太大则图集消耗快。图集尺寸 1024×1024 是常见起点,字符集大的语种(比如中文)可能需要 2048 甚至更大,或者依赖多图集。AtlasPopulationMode.Dynamic是动态字体的关键,它让 TMP 在遇到图集里没有的字符时才去生成,而不是一次性把所有字符都渲染进去。
GlyphRenderMode.SDFAA选择的是带抗锯齿的 SDF 渲染,这是 TMP 的默认推荐模式,支持描边、阴影等效果,缩放时边缘也比较干净。如果项目对性能极度敏感、不需要这些效果,可以换成位图模式,但多数 UI 场景没必要。
3. 跑起来:环境准备、最小可运行示例与验证
3.1 环境与依赖确认
这个项目依赖 Unity 和 TextMeshPro 包。Unity 版本建议 2021.3 LTS 及以上,因为较早版本里CreateFontAsset从字节数组创建的重载可能不存在或签名不同。TextMeshPro 通过 Package Manager 安装,装完后需要在菜单里执行一次 TMP 的 Essential Resources 导入,否则 TMP 的基础材质和 Shader 缺失,动态字体构建出来也是白块。
平台方面,Windows、macOS、Linux 的编辑器环境都能跑,但要注意 IL2CPP 构建时文件读取权限的问题,后面避坑章节会细说。移动端(Android/iOS)的系统字体访问方式跟桌面完全不同,这个项目的桌面实现不能直接搬过去,需要另写 Provider。
3.2 最小可运行示例
把项目导入后,新建一个场景,挂一个 TMP_Text 组件,然后写一个脚本调用字体管理器。下面是最小示例:
using UnityEngine; using TMPro; public class FontDemo : MonoBehaviour { public TMP_Text targetText; void Start() { // 初始化管理器,内部会根据当前平台选择 Provider FontManager.Initialize(); // 列出系统字体,挑一个支持中文的 var names = FontManager.GetAvailableFontNames(); Debug.Log($"系统字体数量: {names.Count}"); // 按名称获取 FontAsset,内部带缓存 var fontAsset = FontManager.GetFontAsset("msyh"); // 微软雅黑示例名 if (fontAsset != null) { targetText.font = fontAsset; targetText.text = "测试中文与 English 混排"; } else { Debug.LogWarning("目标字体未找到,回退到默认字体"); } } }这段代码的流程是初始化、枚举、获取、赋值四步。FontManager.Initialize()里做的事是根据Application.platform实例化对应的 Provider,并清空缓存。GetFontAsset先查缓存,没有则调 Provider 读字节,再交给FontAssetBuilder构建,构建完塞进缓存。字体名传的是文件名,实际项目里最好做一个字体名到显示名的映射表,避免用户看到一堆看不懂的文件名。
运行后如果 TMP 文本正常显示中文,说明链路通了。如果显示方块,先确认字体本身是否包含中文字形,再确认图集是否够大。
3.3 验证与调试手段
验证分两步。第一步看日志,GetAvailableFontNames返回的数量是否合理,Windows 下通常有几百个字体文件,如果返回 0 说明目录读取失败。第二步看 TMP 的字体资产 Inspector,动态字体在运行时会实时往图集里填字形,选中 FontAsset 能看到图集的填充情况。
调试时一个常用手段是把构建出来的 FontAsset 在运行时Debug.Log出来,检查atlasPopulationMode是否为 Dynamic、atlasWidth和atlasHeight是否符合预期。如果字形模糊,调大采样点大小;如果图集频繁溢出,调大图集尺寸或确认多图集是否开启。
提示:编辑器里调试动态字体时,退出播放模式后运行时生成的 FontAsset 会被销毁,这是正常的,不要试图把它保存成资产,动态字体资产应该在运行时按需重建。
4. 避坑与排查:字体加载链路上的五个真实翻车点
4.1 字体读到了但 TMP 显示方块
现象是日志显示字体字节读取成功,FontAsset 也构建出来了,但文本还是方块。原因通常是字体文件本身不含目标字符,或者构建时采样点大小过小导致字形生成失败。解决方式是先用字体查看工具确认字体覆盖范围,再检查CreateFontAsset的采样点参数,中文场景建议不低于 72。另一个隐蔽原因是 TMP 的默认材质丢失,检查 TMP Essential Resources 是否导入完整。
4.2 打包后读取系统字体目录失败
编辑器里一切正常,构建成可执行文件后字体列表为空。原因是构建后的运行环境权限或路径不同,尤其是 macOS 的沙盒限制和 Linux 的字体目录差异。解决方式是在 Provider 里对每个候选目录做存在性检查,读取失败时记录具体路径和异常信息,而不是静默返回空列表。桌面平台还要注意 32 位/64 位进程对系统目录的访问差异。
4.3 图集溢出导致部分字符不显示
现象是文本前半段正常,后半段突然变方块或消失。原因是动态图集被填满,且多图集没有正确启用。解决方式是构建时把多图集参数设为 true,并在运行时监控fontAsset.atlasTextures.Length,超过阈值时考虑换更大图集或拆分字体。中文项目里这个坑特别常见,因为常用汉字就有几千个。
4.4 字体名与显示名不一致导致找不到字体
用户输入「微软雅黑」找不到,因为系统里文件名是msyh.ttc。原因是枚举返回的是文件名,而用户认知里是字体显示名。解决方式是引入字体元数据解析,读取字体内部的 name 表,或者在项目里维护一份常用字体名映射表。ttc 集合字体还要额外处理,因为一个文件里可能包含多个字体。
4.5 频繁切换字体造成内存增长
现象是反复切换字体后内存持续上涨。原因是每次切换都新建 FontAsset,旧资产没有被释放。解决方式是在 FontManager 里做引用计数或 LRU 缓存,切换时先释放不再使用的 FontAsset,调用Object.Destroy并置空引用。动态字体的图集纹理是显存大户,不释放的话很快就会撑爆。
5. 二次开发:把系统字体能力接进自己的 UI 框架
5.1 扩展 Provider 支持移动端
桌面端的实现思路不能直接搬到移动端。Android 的系统字体在/system/fonts,但普通应用没有读取权限,常见做法是通过 Android 的TypefaceAPI 拿到字体,再想办法转成字节流,或者干脆把需要的字体随包携带、只保留动态图集能力。iOS 则可以用UIFont系列接口枚举字体族。扩展时保持IFontProvider接口不变,新增AndroidFontProvider和IOSFontProvider,在FontManager.Initialize里按平台分支即可。
5.2 字体回退链的设计
单一字体很难覆盖所有字符,实际项目需要回退链。TMP 本身支持fallbackFontAssetTable,可以把多个 FontAsset 串起来,主字体缺字时自动查回退表。二次开发时可以在FontManager里维护一个默认回退链,比如「项目主字体 → 系统中文字体 → 系统英文字体」,获取 FontAsset 时自动把回退表填好。下面是一个设置回退表的片段:
// 把 fallback 列表挂到主字体上 public static void SetupFallback(TMP_FontAsset main, List<TMP_FontAsset> fallbacks) { main.fallbackFontAssetTable.Clear(); foreach (var fb in fallbacks) { if (fb != null && fb != main) main.fallbackFontAssetTable.Add(fb); } }回退表的顺序有讲究,越靠前优先级越高,把覆盖范围最广的字体放前面能减少查找次数。回退链不宜过长,每多一层就多一次图集查找开销。
5.3 验证改造效果的方法
改造后要验证三件事:字符覆盖率、图集增长曲线、切换耗时。字符覆盖率可以用一段包含目标语种全部常用字符的测试文本跑一遍,统计方块数量。图集增长曲线在运行时打印atlasTextures.Length和每个图集的填充率,观察是否在合理范围内收敛。切换耗时用Stopwatch包住GetFontAsset调用,首次构建通常几十到几百毫秒,命中缓存应该接近零。
| 验证项 | 观测手段 | 合格标准 |
|---|---|---|
| 字符覆盖 | 全字符测试文本 | 方块数为 0 |
| 图集增长 | 运行时打印图集数量 | 稳定后不再增长 |
| 切换耗时 | Stopwatch 计时 | 缓存命中 < 1ms |
5.4 一个容易忽略的细节
动态字体在运行时构建的 FontAsset 不会自动序列化,这意味着每次启动都要重新构建,首次打开 UI 会有可感知的卡顿。我的做法是在启动流程里预构建常用字体,把构建放在加载界面后面,用户感知不到。另外,构建参数一旦定下来就不要频繁改,因为不同参数构建出的图集布局不同,改参数等于让之前的缓存全部失效。
从那以后我每次接多语言 UI,都会先把字体回退链和动态图集参数在启动阶段跑一遍验证,确认字符覆盖和图集增长都正常,再往下写业务逻辑。希望这个拆解能帮到你,少走一遍我踩过的弯路。
本文还有配套的精品资源,点击获取