☰
Unity原生系统字体桥接:运行时动态生成TMP字体资产
2026/10/16 6:53:29 网站建设 项目流程

简介:UnityNativeOSFont 是一套面向 Unity 开发者的开源字体解决方案,重点解决 TextMeshPro 默认字体库无法覆盖各操作系统本地字体的问题。它通过 C# 脚本在运行时读取系统字体列表并转换为 Unity 可识别格式,让开发者无需预先导入字体文件,即可在 TMP 组件中动态选用系统字体,适用于电子书、教育软件及需要本地化支持的产品。资源包共 107 个文件,约 1.33MB,包含 13 个 shader、4 个 cginc 等着色器资源,用于抗锯齿、描边等渲染优化;另有 23 个 asset 配置、2 个 cs 脚本及示例场景、说明文档等,结构完整便于快速接入。目前已有 1045 人学习下载。借助其中的脚本与 ShaderLab 示例,读者可掌握系统字体枚举、TMP 动态应用及跨平台兼容处理思路,并参考性能优化建议,在移动端合理控制字体加载带来的内存与计算开销。

1. 当 TMP 字体不够用:一个 Unity 原生字体桥接方案

做过 Unity 项目的人大概率都遇到过这个场景:UI 里要显示用户系统里装的字体,或者要动态加载一个本地字体文件,结果发现 TextMeshPro 的 Font Asset 是预烘焙的,运行时想换一套字形数据,要么重新生成 Asset,要么走 AssetBundle 热更,流程重得让人头疼。UnityNativeOSFont 这个项目解决的就是这个问题——它把操作系统的原生字体接口桥接到 Unity 侧,在运行时枚举系统字体、读取字体文件字节流,再交给 TMP 动态生成 Font Asset。整个链路涉及 C# 与原生代码的互操作、字体字节流的跨边界传递,以及 TMP 动态字体的运行时构建。如果你正在做需要跟随系统字体变化的工具类应用、多语言编辑器,或者想让用户自己选字体的桌面端产品,这个项目的思路值得拆一遍。

2. 架构拆解:原生字体枚举与 TMP 动态资产的桥接链路

2.1 三层结构:原生层、桥接层、Unity 表现层

这个项目的代码组织可以分成三层来看。最底下是原生层,负责跟操作系统打交道——Windows 下走 GDI 或 DirectWrite 的字体枚举接口,macOS 下走 CoreText,Linux 下走 FontConfig。这一层做的事情很纯粹:列出系统里装了哪些字体、每个字体的名称和文件路径是什么、把字体文件读成字节数组。

中间是桥接层,也就是 C# 侧的 P/Invoke 封装。它把原生层暴露出来的函数签名翻译成 C# 能调用的形式,处理字符串编码(原生侧通常是 UTF-8 或宽字符,C# 侧是 UTF-16)、内存分配与释放、数组的跨边界传递。这一层最容易翻车的地方是内存管理——原生侧 malloc 出来的缓冲区,C# 侧用完必须调对应的 free 函数,否则就是稳定的内存泄漏。

最上面是 Unity 表现层,负责把拿到的字体字节流喂给 TMP。TMP 的动态字体机制允许你在运行时创建 Font Asset,核心是TMP_FontAsset.CreateFontAsset这个 API,它接受一个Font对象或者字体字节数据,然后生成运行时资产。拿到这个 Asset 之后,赋值给TextMeshProUGUI.font,UI 就会用新字体重新排版。

三层之间的数据流是这样的:Unity 层发起请求 → 桥接层调用原生函数 → 原生层枚举字体返回列表 → 用户选中某个字体 → 桥接层再次调用原生函数读取该字体的文件字节 → Unity 层用字节流创建 TMP Font Asset → 应用到目标文本组件。

2.2 原生字体枚举的关键实现

先看原生侧枚举字体的核心逻辑。以 Windows 为例,用 GDI 的EnumFontFamiliesEx是最直接的做法,代码大致长这样:

// 原生侧:枚举系统字体,回调收集字体名称 int CALLBACK EnumFontCallback( const LOGFONT* lpelfe, const TEXTMETRIC* lpntme, DWORD fontType, LPARAM lParam) { // lParam 传进来的是一个 vector<string>*,用来收集结果 auto* fontList = reinterpret_cast<std::vector<std::string>*>(lParam); // 只收集 TrueType 字体,位图字体对 TMP 没用 if (fontType & TRUETYPE_FONTTYPE) { // 把宽字符字体名转成 UTF-8 存起来 std::wstring wname(lpelfe->lfFaceName); std::string name(wname.begin(), wname.end()); fontList->push_back(name); } return 1; // 返回非零继续枚举 } // 对外暴露的枚举入口 extern "C" int EnumerateSystemFonts(char*** outNames, int* outCount) { std::vector<std::string> fonts; LOGFONT lf = {}; lf.lfCharSet = DEFAULT_CHARSET; HDC hdc = GetDC(nullptr); EnumFontFamiliesEx(hdc, &lf, (FONTENUMPROC)EnumFontCallback, (LPARAM)&fonts, 0); ReleaseDC(nullptr, hdc); // 把结果拷贝到堆上,交给 C# 侧释放 *outCount = (int)fonts.size(); *outNames = (char**)malloc(sizeof(char*) * fonts.size()); for (size_t i = 0; i < fonts.size(); i++) { (*outNames)[i] = _strdup(fonts[i].c_str()); } return 0; }

这段代码有几个点值得注意。EnumFontFamiliesEx的回调返回值必须是非零才会继续枚举,返回 0 就停了,这是 Win32 API 的惯例。TRUETYPE_FONTTYPE过滤掉位图字体,因为 TMP 只认 TrueType/OpenType 轮廓。最后把std::string拷贝到malloc出来的堆内存上,是因为 C# 侧要通过 P/Invoke 读这块内存,必须保证生命周期跨越边界——栈上的std::string出了函数就没了。

对应的 C# 侧声明:

using System; using System.Runtime.InteropServices; public static class NativeFontBridge { // 原生函数声明,注意 CallingConvention 要和原生侧一致 [DllImport("NativeFontPlugin", CallingConvention = CallingConvention.Cdecl)] private static extern int EnumerateSystemFonts( out IntPtr outNames, out int outCount); // 释放原生侧 malloc 出来的字符串数组 [DllImport("NativeFontPlugin", CallingConvention = CallingConvention.Cdecl)] private static extern void FreeFontList(IntPtr names, int count); public static string[] GetSystemFontNames() { IntPtr namesPtr; int count; int ret = EnumerateSystemFonts(out namesPtr, out count); if (ret != 0 || count == 0) return Array.Empty<string>(); var result = new string[count]; for (int i = 0; i < count; i++) { // 逐个读取 char* 指针 IntPtr strPtr = Marshal.ReadIntPtr(namesPtr, i * IntPtr.Size); result[i] = Marshal.PtrToStringAnsi(strPtr); } // 关键:必须释放原生内存,否则每次调用泄漏 FreeFontList(namesPtr, count); return result; } }

Marshal.ReadIntPtr按指针大小偏移读取数组里的每个char*,Marshal.PtrToStringAnsi把 ANSI 字符串转成 C# 的string。这里用 ANSI 而不是 UTF-8 是因为 Windows 的_strdup存的是本地编码,如果字体名包含中文,需要改成宽字符版本PtrToStringUni并调整原生侧的编码转换。FreeFontList的调用是必须的,漏掉就是每次枚举泄漏一批字符串。

2.3 从字体文件到 TMP Font Asset

拿到字体名称之后,下一步是读取字体文件的字节流。原生侧根据字体名称找到对应的文件路径,用标准文件 IO 读成字节数组返回:

extern "C" int LoadFontFileData(const char* fontName, unsigned char** outData, int* outSize) { // 根据字体名查注册表或 FontConfig 拿到文件路径 std::string path = ResolveFontPath(fontName); if (path.empty()) return -1; std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file.is_open()) return -2; std::streamsize size = file.tellg(); file.seekg(0, std::ios::beg); *outData = (unsigned char*)malloc(size); if (!file.read((char*)*outData, size)) { free(*outData); return -3; } *outSize = (int)size; return 0; }

C# 侧接收字节数组后,创建 TMP 动态字体:

using TMPro; using UnityEngine; public class DynamicFontLoader : MonoBehaviour { public TextMeshProUGUI targetText; public void ApplySystemFont(string fontName) { // 1. 从原生侧拿到字体文件字节 byte[] fontData = NativeFontBridge.LoadFontData(fontName); if (fontData == null || fontData.Length == 0) { Debug.LogError($"字体 {fontName} 加载失败"); return; } // 2. 创建临时 Font 对象承载字节数据 // 注意:Font.CreateDynamicFontFromOSFont 只接受系统字体名, // 这里用字节流方式是为了兼容非安装字体 Font font = new Font(fontName); // 实际项目中通常走 FontEngine.LoadFontFace 加载字节流 // 这里简化示意,真实实现需要处理 FontEngine 的错误码 // 3. 创建 TMP 动态 Font Asset TMP_FontAsset dynamicAsset = TMP_FontAsset.CreateFontAsset( font, 90, // 采样点大小,影响字形精度 9, // 图集 padding,防止字形边缘裁切 UnityEngine.TextCore.LowLevel.GlyphRenderMode.SDFAA, 1024, // 图集宽度 1024, // 图集高度 AtlasPopulationMode.Dynamic, // 动态模式,按需添加字形 true // 启用多图集,字形多了自动扩展 ); // 4. 应用到目标文本 targetText.font = dynamicAsset; targetText.SetAllDirty(); // 强制重新排版 } }

CreateFontAsset的参数里,采样点大小 90 是 TMP 默认值,调大字形更精细但图集占用更大;padding 9 是为了 SDF 渲染时边缘有足够渐变空间;AtlasPopulationMode.Dynamic是关键,它让 TMP 在遇到图集里没有的字符时自动渲染并加入图集,而不是显示方块。图集初始 1024x1024,开启多图集后字形溢出会自动开新图集页。

SetAllDirty这一步不能省。直接改font属性有时候不会触发重排,尤其是文本内容没变的情况下,TMP 可能认为布局是干净的。调SetAllDirty强制走一遍完整的布局和网格重建。

3. 跑起来:从零编译原生插件到 Unity 场景验证

3.1 原生插件的编译与放置

这个项目的原生部分需要单独编译成动态库。Windows 下用 CMake 生成 Visual Studio 工程,编译出NativeFontPlugin.dll;macOS 下编译出.bundle或.dylib;Linux 下是.so。编译命令大致如下:

# Windows (在 Developer Command Prompt 里) mkdir build && cd build cmake .. -G "Visual Studio 17 2022" -A x64 cmake --build . --config Release # macOS mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make # Linux mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make

编译产物要放到 Unity 项目的Assets/Plugins目录下,按平台分文件夹:

Assets/ Plugins/ x86_64/ NativeFontPlugin.dll # Windows 64位 macOS/ NativeFontPlugin.bundle # macOS Linux/ libNativeFontPlugin.so # Linux

注意:Windows 下如果编译的是 64 位 DLL,Unity 的 Editor 也必须是 64 位,否则 P/Invoke 会报BadImageFormatException。这个错误信息很隐晦,新手容易以为是 DLL 本身坏了。

CMakeLists.txt 里要确保导出符号正确。Windows 下需要.def文件或者__declspec(dllexport),否则DllImport找不到入口点:

cmake_minimum_required(VERSION 3.15) project(NativeFontPlugin) add_library(NativeFontPlugin SHARED src/font_enumerator.cpp src/font_loader.cpp ) # 导出所有 extern "C" 符号 set_target_properties(NativeFontPlugin PROPERTIES CXX_STANDARD 17 POSITION_INDEPENDENT_CODE ON ) if(WIN32) # Windows 下链接 GDI32 用于字体枚举 target_link_libraries(NativeFontPlugin gdi32) endif()

3.2 Unity 侧的场景搭建与验证

原生库放好之后,Unity 侧建一个空场景,挂一个 Canvas,里面放一个TextMeshProUGUI组件。再建一个空 GameObject 挂DynamicFontLoader脚本,把 Text 组件拖到targetText字段上。

验证流程分三步走。第一步先验证字体枚举能不能通:

void Start() { string[] fonts = NativeFontBridge.GetSystemFontNames(); Debug.Log($"枚举到 {fonts.Length} 个系统字体"); foreach (var f in fonts) { Debug.Log($" - {f}"); } }

如果 Console 里打出一串字体名,说明原生枚举和 P/Invoke 链路是通的。如果报DllNotFoundException,检查 DLL 是不是放对了平台文件夹;如果报EntryPointNotFoundException,检查函数名和导出符号是否匹配。

第二步验证字体文件读取。挑一个系统里确定存在的字体名,调LoadFontData,看返回的字节数组长度是否合理——一个中文字体动辄十几 MB,英文字体通常几百 KB 到几 MB。如果返回空或者长度是 0,说明路径解析逻辑有问题。

第三步才是 TMP 动态资产创建。这一步最容易出问题的是CreateFontAsset返回 null。常见原因是字体字节流不是有效的 TrueType/OpenType 格式,或者Font对象创建失败。可以在创建后加一个判空:

if (dynamicAsset == null) { Debug.LogError("TMP Font Asset 创建失败,检查字体数据是否有效"); return; }

3.3 运行时切换字体的完整调用链

把三步串起来,一个完整的运行时字体切换流程是这样的:

public void SwitchToFont(string fontName) { // 1. 读取字体文件字节 byte[] data = NativeFontBridge.LoadFontData(fontName); if (data == null) { Debug.LogError("读取失败"); return; } // 2. 通过 FontEngine 加载字体面 var error = FontEngine.LoadFontFace(data, 90); if (error != FontEngineError.Success) { Debug.LogError($"FontEngine 加载失败: {error}"); return; } // 3. 创建 TMP 动态资产 var asset = TMP_FontAsset.CreateFontAsset( Font.GetDefault(), 90, 9, GlyphRenderMode.SDFAA, 1024, 1024, AtlasPopulationMode.Dynamic, true); // 4. 应用并刷新 targetText.font = asset; targetText.SetAllDirty(); }

FontEngine.LoadFontFace是 TMP 底层加载字体的入口,它接受字节数组和采样点大小,返回错误码。这一步成功之后,CreateFontAsset才能拿到有效的字形数据。整个链路里,FontEngine是黑匣子最深的一环,出错信息往往不够具体,所以每一步都要判空和打日志。

4. 避坑与排查:原生互操作和 TMP 动态字体的五个血泪经验

4.1 内存泄漏:原生 malloc 的字符串数组没释放

现象是每次调用GetSystemFontNames之后,进程内存涨一点,调用几百次之后内存占用明显上升。原因是原生侧_strdup和malloc分配的内存没有对应的释放调用。解决方式是在 C# 侧封装一个try/finally,确保FreeFontList一定被调用:

public static string[] GetSystemFontNames() { IntPtr namesPtr; int count; int ret = EnumerateSystemFonts(out namesPtr, out count); if (ret != 0 || count == 0) return Array.Empty<string>(); try { var result = new string[count]; for (int i = 0; i < count; i++) { IntPtr strPtr = Marshal.ReadIntPtr(namesPtr, i * IntPtr.Size); result[i] = Marshal.PtrToStringAnsi(strPtr); } return result; } finally { FreeFontList(namesPtr, count); } }

原生侧的FreeFontList要逐个free每个字符串指针,再free数组本身,顺序不能反。

4.2 字体名编码错乱:中文系统字体显示为乱码

现象是枚举出来的字体名里,中文名称变成问号或者乱码。原因是 Windows 的LOGFONT.lfFaceName是宽字符,代码里直接按std::string逐字节拷贝,丢失了编码信息。解决方式是原生侧统一转成 UTF-8 再返回,C# 侧用PtrToStringUTF8或者手动解码:

// 原生侧:宽字符转 UTF-8 std::wstring wname(lpelfe->lfFaceName); int len = WideCharToMultiByte(CP_UTF8, 0, wname.c_str(), -1, nullptr, 0, nullptr, nullptr); std::string utf8Name(len - 1, 0); WideCharToMultiByte(CP_UTF8, 0, wname.c_str(), -1, &utf8Name[0], len, nullptr, nullptr);

C# 侧对应改成Marshal.PtrToStringUTF8(.NET Standard 2.1 以上支持),或者用Marshal.Copy拿到字节数组后Encoding.UTF8.GetString。

4.3 TMP 图集爆满:动态字体渲染大量字符后图集溢出

现象是文本显示到一定数量后,新字符变成方块或者不显示。原因是动态图集默认只有一页,字形填满之后没有空间了。解决方式是在CreateFontAsset时开启多图集,并且设置合理的图集尺寸:

var asset = TMP_FontAsset.CreateFontAsset( font, 90, 9, GlyphRenderMode.SDFAA, 2048, 2048, // 图集开大一点 AtlasPopulationMode.Dynamic, true); // 多图集开启

如果还是不够,可以在运行时监听asset.atlasTextures.Length,超过阈值时手动触发图集整理,或者降低采样点大小来减少单个字形的像素占用。

4.4 P/Invoke 调用约定不匹配导致栈损坏

现象是调用原生函数后 Unity 直接崩溃,或者返回莫名其妙的值。原因是 C# 的DllImport默认调用约定是StdCall,而原生侧编译出来的是Cdecl。解决方式是在DllImport里显式指定:

[DllImport("NativeFontPlugin", CallingConvention = CallingConvention.Cdecl)]

原生侧的函数声明也要加extern "C"防止 C++ 名称修饰,否则入口点名字会变成_EnumerateSystemFonts@8这种形式,DllImport找不到。

4.5 macOS 下 .bundle 签名与加载失败

现象是 macOS 下 Unity 报DllNotFoundException,但文件明明在。原因是 macOS 对动态库有签名和隔离属性要求,从网络下载或者从其他机器拷贝过来的.bundle可能带 quarantine 属性。解决方式是在终端里去掉隔离属性:

xattr -dr com.apple.quarantine NativeFontPlugin.bundle

另外 macOS 下DllImport的文件名不带.bundle后缀,直接写NativeFontPlugin即可,Unity 会自动补全。

5. 二次开发:把系统字体桥接扩展成完整的字体管理模块

5.1 从枚举到缓存:加一层字体元数据管理

原生项目只做了枚举和读取,实际产品里通常需要缓存。我一般会在 C# 侧加一个FontMetadataCache,把字体名称、文件路径、文件大小、最后修改时间存起来,避免每次切换都重新读文件:

[Serializable] public class FontMetadata { public string name; public string filePath; public long fileSize; public long lastWriteTicks; } public class FontMetadataCache { private Dictionary<string, FontMetadata> _cache = new(); public FontMetadata GetOrLoad(string fontName) { if (_cache.TryGetValue(fontName, out var meta)) { // 检查文件是否被修改过 var info = new FileInfo(meta.filePath); if (info.LastWriteTime.Ticks == meta.lastWriteTicks) return meta; } // 重新读取并更新缓存 var fresh = LoadMetadata(fontName); _cache[fontName] = fresh; return fresh; } }

缓存失效的判断用文件修改时间而不是每次都读内容,对于大字体文件能省不少 IO。如果字体文件被外部程序替换了,修改时间会变,缓存自动失效。

5.2 字体回退链:让 TMP 在缺字时自动切换

TMP 支持字体回退(Fallback),当主字体没有某个字符时,会依次查回退列表。利用这个机制,可以把系统字体做成回退链,主字体用项目内置的,缺字时自动落到系统字体上:

public void SetupFallbackChain(TMP_FontAsset mainFont, string[] systemFonts) { mainFont.fallbackFontAssetTable.Clear(); foreach (var name in systemFonts) { var asset = CreateDynamicAsset(name); if (asset != null) mainFont.fallbackFontAssetTable.Add(asset); } }

回退链的顺序有讲究:把覆盖字符集最广的字体放前面,特殊字体放后面。中文字体通常覆盖几万个字形,放前面能减少回退次数。但回退链太长会影响首次渲染性能,因为 TMP 要逐个查表,建议控制在 5 个以内。

5.3 验证改造效果:一个可复现的测试场景

改完之后怎么验证?我习惯建一个测试场景,放三个 Text 组件:第一个用内置字体,第二个用系统字体,第三个用回退链。然后写一个测试脚本,循环切换字体并输出每个字符的渲染结果:

IEnumerator TestFontSwitching() { string[] testStrings = { "Hello", "你好世界", "日本語テスト", "한국어" }; foreach (var s in testStrings) { targetText.text = s; yield return new WaitForEndOfFrame(); // 检查是否有缺字(TMP 缺字会显示为方块或空白) var textInfo = targetText.textInfo; int missing = 0; foreach (var charInfo in textInfo.characterInfo) { if (charInfo.character == '\0') missing++; } Debug.Log($"字符串 [{s}] 缺字数: {missing}"); } }

这个测试能快速暴露字体覆盖不全的问题。如果某个语言的缺字数不为零,说明回退链里没有覆盖该语言字形的字体,需要调整回退顺序或者补充字体。

从那以后我每次做原生互操作,都强制走一遍「枚举 → 读取 → 创建资产 → 应用 → 释放」的完整链路,每一步都加判空和日志,绝不跳过释放步骤。原生内存泄漏这种东西,开发期看不出来,上线后就是定时炸弹。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询