简介:UnityNativeOSFont 是一款面向 Unity 开发者的开源字体获取方案,解决默认 TMP 字体管理无法覆盖操作系统全部本地字体的问题。它通过 C# 在运行时读取系统字体列表,转换为 TextMeshPro 可用格式,并在 ShaderLab 自定义着色器的辅助下优化抗锯齿、阴影、描边等显示效果,适合电子书、教育软件及本地化产品等追求文本个性化展示的场景。
压缩包共 107 个文件,整体仅 1.33MB,核心文件包括 13 个 shader、4 个 cginc、2 个 cs 脚本及配套 asset、mat、预设和示例场景,另有 pdf、md 文档与 ttf 字体便于查阅。已有 1045 人学习下载。
目录结构清晰,下载后对照文档即可掌握 API 调用方式。借助示例中的字体枚举、动态切换和 Shader 渲染链路,能快速实现“系统字体列表获取—应用到 TMP 组件—跨平台表现适配”的完整流程,并有效降低字体打包体积。对于想提升本地化文本显示效果的中高级开发者,这套含 Shader 优化思路的轻量方案具备实用参考价值。
1. 系统字体直接进 TMP:动态字体这件麻烦事,终于有了个痛快的解
在 Unity 里做多语言界面,字体永远绕不开。成品字体文件动辄几十 MB,塞进包里既占空间又可能踩授权红线;想在运行时拿用户系统里已经装好的字体来用,引擎默认连遍历系统字体的接口都不给。UnityNativeOSFont 这个资源解决的就是这个空洞:从 Windows、macOS、Linux、Android、iOS 上把系统字体路径挖出来,再交给 TextMeshPro 当动态字体源,配合 ShaderLab 处理彩色字形和描边显示。适合本地化工具、聊天类 UI、以及不想为每种语言都打包一套字体的项目。就算你从来没写过字体加载逻辑,照着资源里的封装也能在半天内把它接进现有 UI。
2. 动态 TMP 背后:字体资产、系统字体枚举与 ShaderLab 的三方配合
2.1 动态字体资产和静态字体资产差在哪
TextMeshPro 的字体资产有 Static 和 Dynamic 两种模式。Static 模式下,开发阶段就把选好的字形全部烘焙进一张图集,运行时直接采样图集,性能最好,但图集大小被字符集撑死,中文几千个常用字就直奔 10MB 起步。Dynamic 模式则完全不同:它只保留一个对源字体文件的引用,外加一张相对较小的动态图集,当文本里某个字符第一次出现时,TMP 才从源字体文件里提取字形并写入这张图集。好处是包体小,坏处是第一次碰到生僻字时会有可感知的卡顿。
Unity 自带的动态字体能力有一个尴尬限制:你想在运行时指定动态字体源,通常只能指向 Resources 目录下的字体文件,或者靠Font.CreateDynamicFontFromOSFont在桌面平台上碰运气。移动端上这个方法经常返回空对象,而且它需要传字体名而不是字体路径,遇到系统字体名本地化和同名字体覆盖时就懵了。UnityNativeOSFont 这套方案的核心思路,是先独立完成“系统字体枚举 + 路径定位”,拿到准确路径后再交给 TMP 的创建接口,而不是像CreateDynamicFontFromOSFont那样让引擎去猜。这条路绕开了 Unity 对字体源路径的种种限制,也让动态 TMP 真正可以在移动设备上落地。
2.2 系统字体目录到底怎么找
这是整个资源里最不值得自己重新发明轮子的部分。Windows 上字体目录是系统固定目录,通过Environment.GetFolderPath(Environment.SpecialFolder.Fonts)就能拿到,但里面字体文件命名和显示名称经常对不上;macOS 的字体分散在系统目录、局域网通用目录和用户目录三处,需要逐个扫描;Linux 发行版通常放在/usr/share/fonts和用户 home 下的.fonts目录,但不保证路径一定存在。Android 从 API 29 开始才提供系统的字体列表接口,低版本只能老老实实遍历/system/fonts加/sdcard/fonts;iOS 的沙盒机制最麻烦,你拿到的字体文件路径并不能直接打开读取,常见做法是先通过 CoreText 拿到字体 URL,再把它复制到当前应用的临时目录,然后再交给 Unity 的 C# 侧。
这个资源的抽象做得比较克制,对外暴露的核心接口就一个:根据字体显示名称返回真实文件路径。内部它按运行时平台分流,桌面端用文件系统遍历加字体名匹配,Android 用Typeface加系统字体目录扫描,iOS 走复制到可写目录的流程。我这边实际遇到最多的情况不是“找不到路径”,而是“找到了路径但打开失败”,所以在接入时最好在拿到路径之后立刻做一次文件可读性验证,而不是直接把路径丢给 TMP 创建接口,否则只能在控制台看到一个不是很好定位的异常。
2.3 ShaderLab 在这里到底解决什么问题
很多人看到 ShaderLab 会以为这个资源是个纯渲染工具,其实它只是把着色器作为“字体链路里的最后一块拼图”提供。系统字体和工程内置字体有一个本质区别:字形来源不可控。Windows 自带的彩色字体(COLR/CPAL 格式)如果直接丢给 TMP 默认的 Distance Field 着色器,字号一大,字形边缘会出现灰边甚至发黑;部分旧字体里的符号还带特殊的轮廓图元,默认着色器采样后颜色会错。这些不是 TMP 的 bug,而是字体格式与着色器采样方式不匹配。
我一般会在接入时单独写一个 ShaderLab 变体,声明透明混合、字体图集纹理、顶点色混合,再把Fallback挂回TextMeshPro/Distance Field。这样即使动态图集里的字形格式比较特殊,也能至少在 UI 上正确显示出形状,不会直接变成粉红色方块。ShaderLab 在这条链路里的角色更像是兜底策略:C# 部分负责把所有系统字体文件变成 TMP 能用的动态字体资产,shader 负责让这些来源各异的字形在界面上保持一致观感。
3. 从零接入:拿系统字体、建动态资产、换 ShaderLab 渲染
3.1 核心代码:把系统字体转成 TMP 动态字体资产
先看我自己习惯的封装方式。这个函数接收字体显示名称和采样尺寸,返回一个TMP_FontAsset。里面分两条线:桌面端优先用引擎自带的 OS 字体接口,失败后回退到文件字节处理,移动端走文件路径验证。
using System.IO; using TMPro; using UnityEngine; public static class NativeFontToTmp { public static TMP_FontAsset CreateDynamicTMPFont(string fontName, string fontPath, int sampleSize) { Font osFont = null; // 桌面端:Unity 自带接口可以直接按字体名创建动态 Font osFont = Font.CreateDynamicFontFromOSFont(fontName, sampleSize); if (osFont == null && !string.IsNullOrEmpty(fontPath)) { if (!File.Exists(fontPath)) { Debug.LogError($"font path not available: {fontPath}"); return null; } // 移动端常规做法:先把系统字体文件复制到可写目录,再按路径加载 string localCopy = Path.Combine(Application.temporaryCachePath, Path.GetFileName(fontPath)); if (!File.Exists(localCopy)) { File.Copy(fontPath, localCopy); } // 这里实际上会走原生插件或反射去构造 Font 对象,逻辑较绕, // 我通常把它收敛到一个平台行为类里,避免到处判断平台宏。 osFont = LoadFontFromFile(localCopy, fontName, sampleSize); } if (osFont == null) { Debug.LogError($"failed to load system font: {fontName}"); return null; } TMP_FontAsset tmpAsset = TMP_FontAsset.CreateFontAsset( osFont, sampleSize, 9, // atlasPadding,防止字形贴边 GlyphRenderMode.SDFAA, 1024, // atlasWidth 1024, // atlasHeight AtlasPopulationMode.Dynamic, true // enableMultiAtlasSupport ); return tmpAsset; } private static Font LoadFontFromFile(string path, string name, int size) { // 不同平台的构造差异太大,实际资源包内部会有 AndroidJavaObject / CoreText 封装。 // 这里只做示意,重点是可以在此处拿到 Font 对象。 Font font = new Font(name); // 有些平台允许直接 new Font(path),有些不行; // 我会用一个平台辅助类包装,避免上层代码被平台宏铺满。 return font; } }逻辑说明:Font.CreateDynamicFontFromOSFont在 macOS 上通常能直接用名字命中,Windows 上要注意传入的fontName必须是 Windows 显示名称列表里那个名字,否则返回 null。回退路径里先把字体文件复制到Application.temporaryCachePath,是为了让 iOS 沙盒内可读,也避免 Android 上长期占用系统目录句柄。
参数说明:sampleSize建议与运行时最常见的 UI 字号保持同一量级,不要为了“清晰”一味拉到 512,那样动态图集在 1024×1024 的尺寸下装不了几个字形。atlasPadding = 9是我个人反复试出来的下限,低于 6 会让带圈字符的轮廓与旁字形粘连。GlyphRenderMode.SDFAA表示生成带抗锯齿的距离场图集,适合 UI 文本;普通位图模式渲染非中文字体会更锐利,但遇上复杂曲线时边缘容易崩。
3.2 把动态字体接到 TextMeshPro 组件上
拿到字体资产后,接下来就是把它赋给 UI 文本组件,并强制刷新一次网格,避免首帧绘制时才去生成字形导致卡顿。
using TMPro; using UnityEngine; public class ApplySystemFont : MonoBehaviour { public TextMeshProUGUI label; public string systemFontName = "Noto Sans CJK SC"; public void Apply() { string fontPath = SystemFontUtility.GetSystemFontPath(systemFontName); TMP_FontAsset dynamicFont = NativeFontToTmp.CreateDynamicTMPFont(systemFontName, fontPath, 128); if (dynamicFont == null) { label.font = TMP_Settings.defaultFontAsset; label.ForceMeshUpdate(); return; } label.font = dynamicFont; label.fontMaterial = dynamicFont.material; label.ForceMeshUpdate(); } }这里SystemFontUtility.GetSystemFontPath就是资源包里提供的系统字体枚举接口,我自己的项目里会在它内部跑一个字体名模糊匹配,因为 Windows 下的中文显示名有时带括号,例如“微软雅黑 (TrueType)”,直接相等匹配很容易落空。ForceMeshUpdate并不是必须的,但它会提前触发动态图集的字形生成,把开销从用户看到文字的那一帧挪到你主动调用这一刻,对聊天界面特别友好。
3.3 配套 ShaderLab:给动态字体资产换一个更稳的渲染通道
下面这个 shader 是我在项目里常用的简化版,它不做 SDF 特殊处理,只是把字体图集按常规透明纹理采样。如果你用非 SDF 的动态图集,这个 shader 比 TMP 的 Distance Field 更适合兜底。
Shader "Custom/TMPNativeDynamicFont" { Properties { _MainTex ("Font Atlas", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) _Cutoff ("Alpha Cutoff", Range(0,1)) = 0.5 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Cutoff; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.color = v.color * _Color; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * i.color; clip(col.a - _Cutoff); return col; } ENDCG } } FallBack "TextMeshPro/Distance Field" }逻辑说明:这个 shader 没有使用 TMP 的 SDF 采样函数,因此不会对距离场图集做特殊解码,它只把_MainTex当作普通 alpha 贴图用。clip(col.a - _Cutoff)会丢弃半透明像素,避免文字边缘出现脏色。
参数说明:_Cutoff我通常设在 0.3 到 0.5 之间,系统字体里细体字形的 alpha 值偏低,设太高会把字切断。使用时把动态字体资产的material.shader指向这个 shader,然后调用ShaderUtilities.UpdateShaderValues刷新参数,不然 TMP 的自检逻辑会把材质标成无效。
dynamicFont.material.shader = Shader.Find("Custom/TMPNativeDynamicFont"); ShaderUtilities.UpdateShaderValues(dynamicFont.material); label.fontMaterial = dynamicFont.material;ShaderUtilities.UpdateShaderValues会重新计算面光、描边等 TMP 专属数据。如果你用的是我上面的简化 shader,这一步会忽略大部分参数,但调用一下不会出错,也能避免材质在 Inspector 里显示成“缺参数”的粉红色。
4. 避坑与排查:系统字体动态化时的五个高频翻车点
4.1 手机上拿到的字体路径存在,但打开就崩
现象:File.Exists返回 true,但在 Android 或 iOS 上直接File.ReadAllBytes或new Font(path)抛异常,有时干脆返回一个空对象。
原因:移动端对系统字体目录的读取有沙盒限制。Android 上/system/fonts下的文件即使能stat,也不代表你有读取权限;iOS 更是连系统字体文件 URL 都拿不到,只能通过系统 API 间接访问。
解决:不直接读系统路径。先把字体文件复制到Application.temporaryCachePath下的独立目录,再从这个目录加载。复制操作也可能失败,所以复制后要立刻做一次字节长度校验,防止拿到 0 字节文件。iOS 上这一步我通常用原生代码把 CoreText 返回的字体 URL 转成文件引用,再复制到沙盒临时目录,资源包里已经封装好了这个动作。
4.2 IL2CPP 构建后动态字体全部失效
现象:编辑器里跑得好好的,构建成 Android IL2CPP 包后,运行时拿到字体路径,创建出的 TMP 字体资产却是空的,文本完全不显示。
原因:IL2CPP 会裁剪掉未被静态引用的系统字体桥接代码,而Font.CreateDynamicFontFromOSFont在不支持的平台上返回 null 后,回退路径没有及时报错,导致后续在 null 对象上硬调接口,界面自然全黑。
解决:接入后第一时间跑一遍 Release 构建,不要只在编辑器里验证。在CreateDynamicTMPFont里加一个明确的单测:构建包里运行 10 秒后检查TMP_FontAsset是否真的创建成功,失败时输出平台宏和字体路径。如果是桥接代码被裁剪,就用[Preserve]或link.xml保住相关类和接口。
4.3 小字号文字糊成一团,放大后边缘发虚
现象:同样一张动态字体资产,字号 12 时笔画粘连成一坨,字号 30 时边缘毛刺明显,完全没法看。
原因:动态图集的采样点数和 padding 设置不合理。sampleSize太小,字形在生成时就没有足够分辨率;atlas padding 小于 6 时,距离场会串到旁边字形。
解决:把sampleSize调到实际 UI 字号的 1.5 倍到 2 倍,atlasPadding至少 9。如果动态图集 1024×1024 不够用,打开enableMultiAtlasSupport,允许 TMP 在需要时自动扩展图集数量,代价是切换图集时会有一次很小的绘制开销。
4.4 第一次切换系统字体时界面卡死几百毫秒
现象:切换字体后,第一帧文本显示速度明显变慢,在低端 Android 机上能达到 600ms 以上。
原因:动态字体资产首次遇到文本中的字符时,需要实时从源字体文件提取字形并写入图集。中文文本一上来就是几百个字符,全部挤在同一帧里生成,主线程自然被拖住。
解决:启动时主动预生成常用字符集。把用户最可能看到的 500 到 1000 个字符丢给TMP_FontAsset.TryAddCharacters,让它们在后台或启动阶段逐步进入图集。也可以在切换字体后调用label.ForceMeshUpdate(),把代价从渲染帧转移到切换动作本身。对于聊天类 UI,我一般还会做一个渐进式加载:先让界面显示默认字体,等动态字体预生成到 80% 再整体切换。
4.5 自定义 ShaderLab 后材质变粉,文本消失
现象:把字体资产的材质替换成自定义 shader 后,Inspector 里看着正常,运行时文本却消失,或者整个界面变成粉紫色。
原因:TMP 的动态字体会在内部维护一组 shader 参数,直接替换material.shader后,TMP 认为是无效材质,回退到默认渲染状态,甚至因为找不到_FaceColor等参数而直接丢弃渲染。
解决:不要直接创建全新 Material 赋值给fontMaterial。正确路径是:先创建或复制出dynamicFont.material,改 shader 后再调用ShaderUtilities.UpdateShaderValues,并且把FallBack "TextMeshPro/Distance Field"写在自定义 shader 尾部。这样即使自定义 pass 出问题,TMP 还能按默认 SDF 渲染,不会彻底黑屏。
5. 验证与进阶:让动态字体在复杂 UI 里不翻车
5.1 运行时验证系统字体真的被 TMP 接受
接入不是做完就算完,我习惯在 UI 上放一个隐藏的验证入口,用一段短文本确认动态字体是否真正可用。下面这段可以用来检查某个字体资产是否具备特定字符的字形。
using TMPro; using UnityEngine; public static class FontVerifier { public static bool IsCharacterReady(TMP_FontAsset font, char character) { if (font == null) { return false; } string pending = character.ToString(); if (font.TryAddCharacters(pending, out TMP_Character[] chars)) { return chars.Length == 1; } return false; } }用法:在切换字体后,对目标 UI 文本里出现的每个唯一字符跑一次IsCharacterReady,如果返回 false,说明动态字体资产里要么没有这个字形,要么源字体文件不支持该字符。这个方法在动态字体上会触发字形生成,所以第一次调用偏慢,不要把整个 UI 的几百个字符都塞进同一帧。我的习惯是用yield return null分帧验证,每帧最多处理 20 个字符,顺便把失败字符汇总到日志里,方便排查是不是字体缺少对应语言字形。
5.2 用 ShaderLab 做一个快速颜色映射技巧
动态字体资产如果包含多色字形,默认 shader 会把颜色信息丢掉。在 ShaderLab 里可以通过_Color和顶点色相乘来做一个整体颜色映射,同时保留字形的 alpha 通道。
fixed4 frag(v2f i) : SV_Target { fixed4 atlas = tex2D(_MainTex, i.uv); // 如果是灰度字体图集,用 r 通道当 alpha,兼容性最好 fixed alpha = atlas.a > 0.01 ? atlas.a : atlas.r; fixed4 col = fixed4(i.color.rgb, i.color.a * alpha); clip(col.a - 0.01); return col; }这套逻辑适合做字体在不改资产的情况下统一换色。很多系统字体不是白色字形,直接乘颜色会出现黑边,所以要优先采样 alpha 通道,rgb 通道只用来看颜色类型。如果 UI 身上需要描边,就继续用 SDF 图集加TextMeshPro/Distance Field的 shader,不要为省事切到这个简化版。
5.3 一个我从血泪里养成的习惯
以前我因为偷懒,只在 Windows 编辑器上验证了动态字体没问题,就把包交给测试,结果 Android 上第一屏就是黑块。后来我每次都强制走一遍“三端验证”:编辑器跑一次、Android Release 包跑一次、iOS 模拟器跑一次,每端都验证两个字符集,一个拉丁字符一个中文生僻字。字体这种东西不像是普通 UI 代码,真出问题用户只看到一片空白,根本报不出有效的 bug 描述。这个习惯让我的字体切换功能在后续项目里几乎没有再翻过车。希望这套验证流程对你也同样管用。
本文还有配套的精品资源,点击获取