简介:UnityNativeOSFont 是一份面向 Unity 开发者的开源插件资源,核心功能是在运行时获取 Windows/macOS/移动端的系统字体,并直接用于 TextMeshPro 文本组件,解决默认字体库不全、无法覆盖多平台字体的痛点。包内共 107 个文件、压缩后仅 1.33MB,关键内容包含 C# 源码、13 个 Shader 与 5 个 CGINC 文件,以及 .asset 字体配置、预设和示例场景,可支撑字体抗锯齿、描边、阴影等自定义渲染效果。目前已有 1045 人学习,特别适合需要动态切换字体的电子书、教育软件、本地化应用等场景。资源附有完整可运行示例和字体着色器预设,能帮助开发者快速搭建系统字体列表获取与选中逻辑,理解跨平台差异;同时可参考其着色器写法,在减少项目内置字体体积的前提下提升文本观感与运行性能。无论是初学者还是进阶开发者,都能从其清晰目录结构和直接可用的示例中快速上手,省去自行对接系统 API 的时间。
1. 系统字体直接喂给 TMP:聊天框为什么不再缺字
实际项目里最难看的一类渲染问题:玩家昵称带一个生僻字,聊天框直接变成空心方块;运营发一条带日文假名的公告,整段文本变成豆腐块。原因是 TMP 默认字体资产的字符集有限,字库里没有的字不可能凭空渲染。为了覆盖这些字,很多人把整套 CJK 字库烘焙进图集,包体立刻增加十几 MB,运营再上新字又得重新出包。UnityNativeOSFont 这个方向要解决的就是这一件事:运行时从操作系统拿字体文件,转成 TMP 动态字体资产,缺哪个字现场取模,不占包体、不等版本更新。
下文按“原理—实现—避坑—多语言—验证”展开,把从系统字体目录到动态 TMP 字体资产的完整链路说透。适合做聊天、昵称、公告、多语言运营位的 Unity 开发者。如果你已经受够了内置字体抠字符集,这套方案值得照做一遍。代码基于 Unity 2019.4 以上和 TextMeshPro 3.x,低版本个别字段需按编译错误微调。
2. UnityNativeOSFont 的原理:动态取字与图集重建机制
2.1 TMP 动态字体在做什么:atlas 取模而不是存字库
静态 TMP 字体资产的制作流程比较固定:把选好的 ttf/otf 放进 TMP Font Asset Creator,设置采样点大小、Padding、渲染模式,TMP 把每个字形烘焙成 SDF 图集,运行时查图集拿 UV。这套流程的问题不是图集本身,而是“提前决定字符集”。一旦字符集固定,新增字符就要重新烘焙,静态资产里的图集尺寸也直接决定了内存上限。
动态字体的机制完全不同。以Font.CreateDynamicFontFromOSFont创建出的 Font 对象具备动态属性,它的图集是运行时按需填充的:文本组件提交渲染时,如果某个字符不在图集里,TMP 会把字符丢给字体引擎取模,再把新字形塞进图集纹理,最后触发一次纹理重建。可以理解为“缺了字才去现刻”,而不是“开印前把整本书的字都刻完”。这也是为什么动态字体的首帧往往有一点顿挫,那是取模,不是卡死。
在 TMP 资产上,这个模式对应AtlasPopulationMode.Dynamic。如果你在 TMP Font Asset Creator 里手动创建过动态字体,Unity 底层也调用了同一个字体引擎,只是编辑器帮你勾选了 Dynamic。搞明白这一点,就知道运行时不需要 TMP 官方工具也能创建动态字体——它本来就不是编辑器专属能力。
2.2 静态、动态、系统字体三者的取舍:包体、冷启动与覆盖字集
项目早期最好把三者的差异列成一张表贴在方案里,省得后期反复争论。核心指标是包体、字集覆盖、首次渲染速度和跨端一致性。
| 方案 | 包体 | 字集覆盖 | 首次渲染 | 跨端一致性 |
|---|---|---|---|---|
| 内置静态 TMP 字体 | 大(MB 级) | 固定,需预先烘焙 | 快 | 一致 |
| 内置动态 TMP 字体 | 中(只带字体文件) | 文件支持的字符几乎全支持 | 首次缺字取模 | 一致 |
| OS 动态字体 | 0(用系统文件) | 取决于系统字体覆盖 | 首次缺字取模 | 不一致 |
从包体看,OS 动态字体优势最明显;聊天这种文本内容不可控的场景,字集覆盖靠系统字体兜底是最省事的路。但代价是跨端显示不一致:Windows 上中文字体轮廓渲染和 macOS 的苹方完全不同,字体引擎和 hinting 策略不一样,同一句话在两端做不到像素级一致。如果产品对 UI 一致性要求苛刻,这个方案从一开始就不该选。
2.3 字体文件从哪来:用代码枚举系统字体目录
拿到系统字体的第一步是知道字体文件在哪。桌面平台最稳的入口是系统字体目录,C# 的Environment.SpecialFolder.Fonts会映射到正确位置:Windows 是系统 Fonts 目录,macOS 是/System/Library/Fonts和/Library/Fonts,Linux 一般是/usr/share/fonts。不用手写平台判断,直接枚举,找不到目录时打印明确错误。
using System; using System.Collections.Generic; using System.IO; using UnityEngine; public static class OSFontLocator { public static List<string> CollectFontFiles() { var result = new List<string>(); string rootDir; try { rootDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); } catch (Exception e) { Debug.LogWarning($"[OSFontLocator] 无法获取系统字体目录: {e.Message}"); return result; } if (string.IsNullOrEmpty(rootDir) || !Directory.Exists(rootDir)) { Debug.LogWarning($"[OSFontLocator] 系统字体目录不存在: {rootDir}"); return result; } var files = Directory.GetFiles(rootDir, "*.*", SearchOption.AllDirectories); foreach (var f in files) { var ext = Path.GetExtension(f).ToLowerInvariant(); if (ext == ".ttf" || ext == ".otf" || ext == ".ttc") result.Add(f); } return result; } }逻辑说明:先取系统字体目录,再递归扫描所有子目录,按扩展名过滤出 ttf/otf/ttc 三类可加载字体文件。递归扫全量在字体很多的 macOS 上可能扫出上百条,但系统字体目录总共也就几十上百个文件,做一次缓存即可,别每次切换文本都重扫。
参数说明:SearchOption.AllDirectories会进入子目录,macOS 的字体目录里嵌套子目录,漏掉会错过部分字重;ttc 是 TrueType Collection,Windows 中文字体集合文件、macOS 的苹方都可能是 ttc 扩展名,第 4 章会专门讲它的脾气。注意这里只负责枚举文件,真正的加载在第 3 章。
3. 从字体文件到动态 FontAsset:可复现的加载链路
3.1 按家族名加载 Font 对象:CreateDynamicFontFromOSFont 的正确姿势
拿到文件路径后,下一步必须创建一个 UnityEngine.Font。核心 API 是Font.CreateDynamicFontFromOSFont(string familyName, int size)。注意第一个参数是操作系统字体家族名,不是完整文件路径,也不是文件名。实战里最大的误会就是把第 2 章枚举出来的C:\Windows\Fonts\xxx.ttc直接塞进去,结果创建出一个空 Font。
正确的做法是用候选字体家族名列表逐个尝试。一个稳定实现如下:
public static Font LoadSystemFont(string preferredFamilyName, int fontSize) { var candidates = new List<string>(); if (!string.IsNullOrEmpty(preferredFamilyName)) candidates.Add(preferredFamilyName); // 按平台补充常用中文字体家族名 candidates.Add("PingFang SC"); candidates.Add("Source Han Sans SC"); candidates.Add("Noto Sans CJK SC"); candidates.Add("sans-serif"); foreach (var family in candidates) { try { var font = Font.CreateDynamicFontFromOSFont(family, fontSize); if (font != null) { Debug.Log($"[OSFontToTMP] 使用系统字体: {family} / name={font.name}"); return font; } } catch (Exception e) { Debug.LogWarning($"[OSFontToTMP] 家族名不可用: {family}, {e.Message}"); } } Debug.LogError("[OSFontToTMP] 所有候选系统字体均加载失败"); return null; }逻辑说明:把调用方传入的优先家族名放在最前,接着按平台补充一套候选清单。CreateDynamicFontFromOSFont在家族名不存在时返回 null,也有部分版本直接抛异常,因此 try-catch 必须包住。打印出来的font.name是 Unity 解析后的资源名,不是家族名,排查时可用来和系统字重做交叉对比。
参数说明:fontSize是字体采样点大小,不是屏幕最终字号。动态字体建议直接给 64,TMP 生成 SDF 图集时会以这个基准做缩放;给 16 会让笔画细节丢失,给 128 会更快打满图集。候选列表里的sans-serif是 Android 的通用无衬线家族名,在 Android 上可作为最后兜底。
3.2 用 TMP_FontAsset.CreateFontAsset 生成动态字体资产
有了 UnityEngine.Font,下一步把它变成 TMP 能用的资产。TMP 提供了运行时工厂方法:TMP_FontAsset.CreateFontAsset(Font font)。这个方法创建的 FontAsset 默认就是动态模式,atlas 按需增长,不会像静态资产那样一次烘焙全部字符。
using TMPro; public static TMP_FontAsset CreateDynamicFontAsset(Font font, int samplingPointSize = 64) { if (font == null) { Debug.LogError("[OSFontToTMP] 无法基于空 Font 创建 TMP 资产"); return null; } var asset = TMP_FontAsset.CreateFontAsset(font); asset.atlasPopulationMode = AtlasPopulationMode.Dynamic; asset.atlasWidth = 1024; asset.atlasHeight = 1024; asset.atlasPadding = 6; asset.atlasRenderMode = GlyphRenderMode.SDFAA; asset.samplingPointSize = samplingPointSize; asset.isMultiAtlasTexturesEnabled = true; return asset; }逻辑说明:CreateFontAsset的默认参数能跑,但默认 atlas 尺寸偏保守,聊天这类高频变化的文本建议手动把宽高开到 1024 并开启多图集支持。isMultiAtlasTexturesEnabled打开后,单个 atlas 打满时会新开一张 atlas 纹理而不是整体重建,能有效避免“刷屏导致大面积卡顿”的经典翻车。
参数说明:atlasPadding是字形之间的间距,SDFAA 模式下取 6 比较稳,太小会导致 SDF 边缘串色;samplingPointSize要和 3.1 里的 fontSize 保持一致,TMP 会用它作为字形采样依据;GlyphRenderMode.SDFAA是动态字体推荐模式,比 SDF 模式边缘更平滑,代价是多用一条颜色通道。
3.3 把 FontAsset 挂到 TMP_Text 并手动触发取字
生成资产后,把它赋给文本框组件,动态字体就真正生效。这一步有两个容易忽略的点:一是必须先赋字体资产再调ForceMeshUpdate,否则文本网格还是旧字体数据;二是如果动态字体第一次渲染某个字符,图集重建发生在渲染管线里,UI 上会出现一次短暂的纹理上传,这是正常的。
public static void ApplyDynamicTMP(TMP_Text text, string familyName, int fontSize) { var font = LoadSystemFont(familyName, fontSize); if (font == null) return; var asset = CreateDynamicFontAsset(font, fontSize); if (asset == null) return; text.font = asset; text.fontSize = fontSize; text.ForceMeshUpdate(); }逻辑说明:ForceMeshUpdate会强制 TMP 重新生成顶点,不调用的话,新字体在编辑器里可能要到下一次文本变化才生效。text.fontSize是屏幕显示字号,和字体的samplingPointSize是两个概念,互不干扰。
如果希望运营文案里的生僻字展示前提前取模,可以手动触发一次字形请求:
var previewText = "椛龘𠮷"; font.RequestCharactersInTexture(previewText, fontSize, FontStyle.Normal); Font.textureRebuilt += tex => { if (tex == font && text.font == asset) text.ForceMeshUpdate(); };逻辑说明:RequestCharactersInTexture是 UnityEngine.Font 层面的字形预加载,调用后字体图集里会提前出现这些字符的纹理,等 TMP 真正渲染时就不用等那一帧的纹理上传。Font.textureRebuilt是静态事件,回调里判断tex == font是为了过滤其他动态字体的重建事件。
参数说明:previewText可以从运营配置表读取,也可以收集一段常用生僻字样本。这只是预加载,不是必须流程。注意事件用完后要Font.textureRebuilt -=取消订阅,否则切换场景后回调链会残留匿名方法。
4. 避坑清单:NativeOSFont 方案的 5 个常见翻车点
4.1 家族名对不上:字体文件在,渲染却是空白
现象:系统字体目录里明明能看到中文字体集合文件,用文件路径加载也拿到了 Font 对象,但 TMP 文本渲染出来是一片空白,或者只能看到默认英文字形。
原因:CreateDynamicFontFromOSFont在部分 Unity 版本允许传文件路径,但内部校验仍然走字体家族名清单;文件路径一旦涉及目录符号链接或大小写不一致,路径能打开而家族名匹配失败,Unity 会回退到默认字体。另一个常见原因是传了 ttc 集合文件路径,而真正可用的家族名不是文件名,导致创建出来的对象是空壳。
解决:不要拿路径当唯一依据。先用候选家族名列表逐个试,加载后打印font.name看实际解析结果。如果必须从路径反推家族名,可以解析 ttf/otf 的 name 表,但没必要为这个写解析器——直接维护一张“平台 → 候选家族名”的静态表更省事。
4.2 .ttc 合集字体文件加载失败
现象:Windows 和 macOS 上的系统中文字体很多是 ttc 集合文件,一个文件包含多个字重。枚举目录能看到,但加载后字体没有字形,或者编辑器下正常、真机上空白。
原因:ttc 本质是多个字体共享轮廓数据的集合,Unity 的字体解析库在部分平台只识别 ttf/otf 文件头,遇到 ttc 会跳过或只加载默认 index。不同 Unity 版本对 ttc 的兼容性差异很大,属于典型的“编辑器正常,打包后翻车”问题。
解决:优先用家族名而不是文件路径。家族名能让字体引擎自己找到 ttc 里的正确 index。如果家族名也失败,用候选数组重试:
var font = Font.CreateDynamicFontFromOSFont( new[] { "PingFang SC", "Noto Sans CJK SC", "sans-serif" }, 64); if (font != null) { // 继续创建 TMP FontAsset }逻辑说明:字符串数组版本会按顺序尝试每个家族名,第一个可用的生效。这个重载在真机上比单字符串版本更耐折腾。注意数组顺序要按目标平台调整,别把所有平台候选都堆在一起,否则加载时长会随着尝试次数增长。
4.3 移动端根本没有 Fonts 目录:别再枚举系统路径
现象:把 2.3 节的枚举逻辑跑到 Android 和 iOS 上,返回空列表。Android 上Environment.SpecialFolder.Fonts指向的目录不存在。
原因:Environment.SpecialFolder.Fonts是桌面 .NET 概念,移动端沙盒里没有这个映射。Android 的系统字体在/system/fonts,iOS 的字体位于系统只读区域,App 沙盒内既不能枚举也不能直接读取。
解决:移动端放弃“枚举文件”思路,改为维护家族名候选表,直接用CreateDynamicFontFromOSFont加载。Android 上sans-serif是稳定兜底,iOS 上PingFang SC是中文兜底。如果非要拿到移动端字体文件,Android 可以考虑把/system/fonts下的目标 ttf 拷到持久化目录,iOS 没有这条路。所以这套方案在多数项目里被定位成“桌面工具链 + 真机验证”,而不是全平台万能钥匙。
4.4 动态图集越滚越大:多语言高频文本下内存失控
现象:动态系统字体跑起来后,聊天、公告、多语言切换同时上,atlas 纹理不断重建,内存曲线一路上涨,某些机型出现纹理峰值。
原因:动态字体图集只增不减。每次遇到新字符,取模后塞进图集,TMP 不会主动清掉长期不用的字符。当文本包含 CJK 扩展区的生僻字、符号混排时,图集被快速打满并触发多图集扩展。
解决:三件事同时做。第一,把atlasWidth/atlasHeight控制在 1024,开启多图集而不是让它无限重建。第二,把高频文本用静态字体资产承载,动态系统字体只做回退。第三,在内存告警或界面切换时重建动态字体资产:销毁旧资产,用同一家族名重新走一遍加载链路,把已经攒下的旧图集整个丢掉。这个“定期重建”相当于给动态图集一剂后悔药,但别每帧做,只在OnLowMemory或 UI 界面切换时做。
4.5 运行时替换字体后材质变粉
现象:代码里对text.font赋了新动态字体资产后,场景里一片粉红,或者上一帧正常、下一帧材质变成洋红色。
原因:TMP 文本组件在 Start 时把字体资产的材质赋给了 Renderer 的 sharedMaterial。运行时替换 font 后,sharedMaterial 没有跟着切,材质通道仍指向旧图集,旧图集被释放后引用悬空,最终呈现粉红。这是运行时替换字体的经典“只改一半”问题。
解决:赋值字体后,把 sharedMaterial 也强制切到新资产上:
text.font = newAsset; text.fontSharedMaterial = newAsset.material; text.ForceMeshUpdate();逻辑说明:fontSharedMaterial是 TMP_Text 的公开属性,切到新资产默认材质后,图集引用才一致。如果后面还要调材质参数,应该克隆材质再改,不要直接改 asset.material,否则会污染所有使用这个动态字体的组件。
5. 多语言与回退链:让动态系统字体接住本地化场景
5.1 配置 fallback 链:西文、中文、日文与韩文混排不乱码
TMP 的字体回退机制是:主字体缺字符时,按顺序查 fallback 列表,fallback 里也没有再往下查。这个机制正好弥补动态系统字体“单个字体字符集不全”的问题——系统字体不是万能,某些老真机上系统中文字体缺日文假名,或缺少扩展区生僻字,靠单一动态字体照样豆腐块。
落地时,我会维护一个资产级 fallback 表:主字体用西文静态字体,数字和英文渲染更稳定;fallback 第一项放中文静态字库,覆盖常用汉字;第二项放 OS 动态字体,兜底生僻字、运营新词和用户输入;第三项放日文或韩文字体。TMP 按顺序查询,这张表的顺序直接影响渲染开销:命中的越早,动态取模次数越少。
var dynamicCjk = CreateDynamicFontAsset(LoadSystemFont("PingFang SC", 64), 64); var baseFont = TMP_FontAsset.CreateFontAsset( baseFontFile, 64, 6, GlyphRenderMode.SDFAA, 1024, 1024, AtlasPopulationMode.Static, true); baseFont.fallbackFontAssetTable = new List<TMP_FontAsset>(); baseFont.fallbackFontAssetTable.Add(dynamicCjk);逻辑说明:fallbackFontAssetTable是 TMP 3.x 的资产级 fallback 列表。低版本 TMP 2.x 字段名是fallbackFontAssets,编译报错时改字段名即可。把动态字体插在 list 里,通用文本走静态主字体,只有遇到生僻字才进入动态字体取模。
参数说明:AtlasPopulationMode.Static表示主字体静态烘焙。动态字体作为 fallback 时,它自己没有静态图集,查询命中同样会触发动态取模,所以 fallback 链别挂超过两层,层级越深,首帧卡顿越明显。
5.2 常用字静态、生僻字动态:混合字体控制图集体积
这个混合方案可以再进一步:主字体还是静态,但字符集只烘焙常用 3500 字加数字、西文、标点;动态系统字体只负责兜底。这样静态图集体积被限制在“常用字”范围内,运营文案里的新词、玩家输入里的生僻字走动态图集,两类内容互不挤占。
public static TMP_FontAsset BuildMixedFont(TMP_FontAsset baseStatic, string dynamicFamily) { var dynamic = CreateDynamicFontAsset(LoadSystemFont(dynamicFamily, 64), 64); if (dynamic == null) return baseStatic; baseStatic.fallbackFontAssetTable = new List<TMP_FontAsset>(); baseStatic.fallbackFontAssetTable.Add(dynamic); return baseStatic; }逻辑说明:这个函数和 5.1 的区别在于主字体是静态资产,字符集会固定在 3500 常用字范围内。文本组件用的还是静态资产,字符命中优先走静态。动态字体只在静态表查不到时才被查询,多数玩家根本触发不到动态取模。dynamicFamily建议从平台配置读取,不同语言包的动态字体家族名可以不同。
实际效果上,静态图集能稳定控制在一张 2048 纹理内,动态图集只在真正遇到生僻字时增长。聊天、公告这类场景跑几天,动态图集也就多几十个生僻字形,内存增长可忽略。
5.3 调 samplingPointSize 与图集上限:性能参数的设定顺序
动态字体性能参数建议按固定顺序调:先定samplingPointSize,再定图集尺寸,最后决定多图集开关。顺序反了会出现“图集改了没效果”的假象,实际是采样点没跟上。
| 参数 | 推荐值 | 作用 | 调大后代价 |
|---|---|---|---|
| samplingPointSize | 64 | 字形采样精度 | 内存与图集占用上升 |
| atlasWidth / atlasHeight | 1024 | 单图集上限 | 纹理显存上升 |
| atlasPadding | 6 | 字形间隔 | 抗串色,但图集利用率下降 |
| isMultiAtlasTexturesEnabled | true | 打满后开新图集 | 多材质批次风险 |
把samplingPointSize从 64 提到 128,图集里每个字形占的像素会成倍增加,同一张 1024 图集能容纳的字数大幅下降。聊天气泡场景 64 够用,运营头条这种大字号展示才需要 128。isMultiAtlasTexturesEnabled开启后,TMP 会在图集打满时创建第二张图集,多个图集意味着文本可能拆成多个材质批次,极端情况下增加 drawcall。所以图集上限不是越大越好,而是“够用 + 不频繁扩展 + 一屏内不拆太多批次”。
还要注意:Font.textureRebuilt回调里不要做重量级操作。图集重建发生在渲染流程里,回调里再触发加载、寻路、GC 分配都会放大这一帧的卡顿。我见过有开发者把整个 UI 刷新逻辑塞进回调,一条聊天消息能卡半秒。
6. 验证与进阶:如何确认文本真的走了系统字体
验证方案是否生效,不能只看屏幕上没豆腐块,还要确认字形究竟来自哪个图集。最直接的方法:在运行时把当前 FontAsset 的 atlas 纹理尺寸、模式和字形数量打出来,对比文本渲染前后有没有新增字形。
private static void DumpAtlasInfo(TMP_FontAsset asset) { var atlas = asset.atlasTexture; Debug.Log($"[校验] atlas尺寸={atlas.width}x{atlas.height}, " + $"模式={asset.atlasPopulationMode}, glyphCount={asset.glyphTable.Count}"); }另一种校验方式:用 UnityEngine.Font 的HasCharacter。动态字体加载后立刻检查目标字符是否存在;如果字符存在而 TMP 文本仍渲染成方块,问题就不在字体加载,而在 TMP 资产或材质引用。
var font = LoadSystemFont("PingFang SC", 64); Debug.Log($"字符[椛]存在={font.HasCharacter('椛')}");进阶思路:按系统语言切换默认字体家族名。同一套 UI,简体中文环境用思源黑体,日文环境切日文字体候选,韩文切韩文候选项。维护一张语言到家族名的小表即可。注意Application.systemLanguage只在启动时稳定,运行时切换语言要重新走一遍“加载 Font → 重建资产 → 赋值组件”的流程。
这个方案也有明确边界。当界面文本量很大且长时间静态展示时,比如图鉴、技能树面板,动态取模的图集增长会让内存不可控,静态烘焙是更优解。当产品对跨端像素一致性有硬要求,OS 动态字体天然不满足。我的习惯是:聊天、昵称、公告这类内容不可预测的文本用动态系统字体兜底,固定界面文本一律走静态字库。上次我把整棵技能树全换成动态字体,一屏图鉴翻下来图集涨了快 6 MB,教训很直观。希望帮到你。
本文还有配套的精品资源,点击获取