ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity动态字体方案:用系统字体池驱动TextMeshPro按需加载

Unity动态字体方案:用系统字体池驱动TextMeshPro按需加载 简介UnityNativeOSFont 是一款帮助 Unity 开发者动态获取操作系统本地字体并用于 TextMeshProTMP的开源插件重点解决默认 TMP 字体管理不包含全部系统字体的问题适合需要个性化文本渲染、本地化支持或轻量化包体的游戏与应用项目。压缩包共 107 个文件大小仅 1.33MB核心包含 C# 脚本cs、ShaderLab 着色器shader、cginc 头文件、材质mat及 TMP Settings 等 asset 资产结构清晰可直接导入工程使用。该资源已有 1045 人学习浏览。通过提供的运行时 API开发者能读取系统字体列表并动态指定给 TMP 组件省去预导入字体文件的步骤同时借助自定义 Shader 可优化字体的抗锯齿、描边与阴影效果提升跨设备可读性。内含示例场景与配置文件便于快速上手并调试不同系统下的字体兼容性适合具备基础 Unity 与 TMP 使用经验的开发者参考。1. UnityNativeOSFont 能做什么把系统字体动态塞进 TMP 的最小闭环UnityNativeOSFont 这个方向解决的问题很直接你的用户机器上装了什么字体程序里就能拿到哪一族的字体并把它们以动态模式写成 TextMeshPro 可用的字体资源。它适合做桌面工具、本地化预览、以及需要跟随系统外观的项目。最吸引人的点在于你不需要把几十兆字体一起打进安装包也不需要每次追加新语言就重新导一次字库。机器上已经装好的字体就是你的资源池动态 TMP 则负责在运行时按需生成字形这两者放在一起等于一条“按系统环境即取即用”的字形链路。但它的适用面并不无限移动端和网页端第一步就会受挫所以动手前必须先搞清楚边界再谈封装。2. 动态 TMP 与系统原生字体先弄懂三个运行边界再动手2.1 动态 TMP 的字符表按需生长的机制以及图集谁在管TextMeshPro 的字体资产有两种常见形态一种是静态字体资产导入时就把选好的字符批量扣进一张图集纹理另一种是动态字体资产它在运行时只保留底层字体引擎与原生字体对象的引用文本组件遇到一个不在字符表里的字形才会向字体引擎发出请求把它渲染进一张动态图集。动态模式的优点是启动体积小、字形覆盖范围由运行时决定缺点是图集不可能无限大而且每次新增字形都可能触发一次图集更新。更具体地说动态 TMP 内部有一个characterTable它维护了当前已经解析过的字符码点与字形索引。文本初始化时TMP_Text组件会把需要显示的字符交给FontAsset的TryAddCharacters流程如果某个字不在表里TMP 会调用底层字体引擎在内存里找到对应字形把它画进一张动态分配的图集纹理。默认情况下动态图集的大小受atlasWidth和atlasHeight限制图集满了之后行为取决于multiAtlasTexturesEnabled允许就再分配一张新图集不允许就直接放弃继续添加。这里有个容易误判的点动态 TMP 的动态指的是“字符表按需生长”并不代表它可以无限制承载所有语言字形。如果你在动态模式下塞入一个几万字符的大型字库内存和加载时间都会变得很难看。正因为这个限制动态模式更适合与“系统字体池”配合把字形来源换成操作系统字体覆盖范围跟着用户环境走但每个字符的具体图集尺寸仍然被 TMP 的图集参数约束。所以动手前先要把图集宽度、高度、padding 和 multi-atlas 开关按项目实际语言范围定好。我一般会把图集宽度设为 1024 或 2048高度尽量用 1024 起步padding 给 4 到 6字符多的语言打开 multi-atlas宁可多几张图集也不要让一张纹理撑爆内存。还要注意一个资源管理习惯动态字体资产应该被当成“一份可重用的字体资源”而不是“某个文本框的私有字体”。同一个系统字体族在项目里只应该创建一次资产之后所有文本组件引用同一个TMP_FontAsset。频繁创建同族动态资产会让图集数目翻倍而且这些运行时生成的资产很难被 GC 正确感知最终表现为内存异常上升。你可以用一个字典缓存家族名和资产的映射后面每次需要字体时先查缓存。2.2 原生字体进 TMP 的三个差异点来源、字重、授权第一点是来源差异。TMP 常用的字体引擎从项目内导入的 TTF/OTF 里加载字体细节而系统字体有两个来源一是操作系统安装的字体文件二是通过字体接口按字体族名直接创建字体对象。两种来源在 TMP 里都能生成动态资产但它们的行为边界不同。按族名创建的方式最快也最简洁但在某些平台只支持已注册到系统的名字按文件路径加载则更可控却要你自己去解析系统字体目录。第二点是字重与样式命名。系统字体枚举接口返回的通常不是你在字体预览里看到的显示名它更接近字体表的内部族名。同一款字体可能有多个样式条目比如名字里带着粗体、中等、细体等字样用含糊的名字去匹配常会得到一个默认字重。填字体族名时你最好先打印一遍列表确认实际名称再写匹配规则别在代码里硬编码一个你没见过的显示名。这个环节看起来是小问题实际项目里最容易在这里翻车编辑器里能显示字体打包后字体应用了错误的字重多半就是名称匹配问题。第三点是授权和平台限制。系统字体并不一定允许你把它随产品进行分发或再发布。即使只是“拿到字体名并在运行时引用”在部分移动平台也会被系统安全策略挡住桌面端的两大系统在构建环境与目标机器上的字体清单也可能不同。你需要准备的“后悔药”是一套后备方案发现系统字体不可用时回退到一个随包内置的静态 TMP 字体资产这是大多数正规项目都在使用的策略。不要把所有 UI 语言都押在系统字体唯一通道上否则一个无人值守的服务器环境就能让你的文本全部消失。2.3 动态模式与 fallback 列表的协作点动态系统字体还有一个容易被忽略的协作对象fallbackFontAssets。当一个文本同时包含拉丁字符和东亚字符而主动态字体里没有后者时TMP 会按顺序去查 fallback 列表。把常用字体设为动态系统字体再把容量较大的兜底字体放在 fallback 里可以让绝大多数新字先命中动态字体只有极端字符才回退到内置资源。顺序写反了所有字符都会优先落到 fallback 字体上动态系统字体形同虚设。这个协作点在真实项目中比单独调字体本身更值得投入时间。此外动态系统字体资产在材质上会生成独立的FontAsset材质。如果你在场景里手动改了某个文本的材质颜色而该文本后来又被切到动态系统字体材质会被 TMP 自动替换你的手改颜色可能丢失。正确做法是把颜色等外观属性统一放在TMP_Settings或默认材质上不要针对运行时生成的动态字体资产做一次性材质修改。这个细节我在第 5 章里会再提一次它是“字体颜色一会儿对一会儿不对”这类玄学问题的常见根源。3. 用 Unity NativeOSFont 思路写出最小实现枚举系统字体并生成动态 TMP3.1 第一步用字体枚举接口拉出系统字体清单在桌面平台上Unity 提供了一条获取操作系统字体名称的接口。第一版封装我会这样做using System; using System.Collections.Generic; using UnityEngine; using TMPro; public static class NativeOSFontProvider { public static Liststring GetInstalledFontFamilies() { var result new Liststring(); try { string[] names Font.GetOSInstalledFontNames(); if (names ! null) { result.AddRange(names); } } catch (Exception ex) { Debug.LogWarning($[NativeOSFont] GetInstalledFontFamilies failed: {ex.Message}); } return result; } }这段代码把系统返回的字体名数组拷贝进Liststring并做了一层异常保护。我会在项目里始终保留这个封装避免每次调用都直接面对底层接口可能抛出的平台异常。参数上没别的可调唯一要解释的是返回值的含义它返回的是“当前进程中可由引擎访问的字体家族名”是运行时视图不是字体文件路径。你在编辑器里能看到几十个名字在打包后的终端环境里看到的可能完全不同正因为这个原因永远不要把这批名字缓存进配置文件。拿到清单后需要一个选择器。常见做法是让玩家自己选、读取本地化配置或者让程序根据当前 UI 语言去匹配。匹配时我建议忽略大小写并去掉空格和连字符做模糊匹配。字体族名在不同系统之间差异很大绝对不要假设“用户系统里一定有某个字体”甚至不要假设“一定有中文字体”。正确姿势是做一个解析方法找不到时返回null上层再走内置回退。具体匹配可以这样写public static string ResolveFamily(string preferred, Liststring candidates) { if (string.IsNullOrEmpty(preferred) || candidates null) return null; string target NormalizeFontName(preferred); foreach (string name in candidates) { if (NormalizeFontName(name) target) return name; } foreach (string name in candidates) { if (NormalizeFontName(name).Contains(target) || target.Contains(NormalizeFontName(name))) { return name; } } return null; } private static string NormalizeFontName(string name) { return name.Replace( , ).Replace(-, ).Replace(_, ).ToLowerInvariant(); }逻辑说明第一轮做严格等值匹配第二轮做包含匹配目的是防止“用户输入名称带空格、实际名称带连字符”这类常见差异。NormalizeFontName把空格、连字符、下划线全部去掉并转小写这样匹配成功率提高不少。注意包含匹配有风险它可能匹配到相近字体族的粗体或斜体变体所以只在精确匹配失败时启用。3.2 第二步把系统字体转换成动态 TMP_FontAsset参数这样定选择好家族名后动态字体资产的创建代码并不算长。核心函数如下public static TMP_FontAsset CreateDynamicFontFromOSFont(string familyName, int pointSize 64) { if (string.IsNullOrEmpty(familyName)) return null; Font osFont Font.CreateDynamicFontFromOSFont(familyName, pointSize); if (osFont null) { Debug.LogWarning($[NativeOSFont] Cannot create OS font for family: {familyName}); return null; } TMP_FontAsset tmpAsset TMP_FontAsset.CreateFontAsset(osFont); if (tmpAsset null) return null; tmpAsset.atlasPopulationMode AtlasPopulationMode.Dynamic; tmpAsset.atlasPadding 6; tmpAsset.multiAtlasTexturesEnabled true; return tmpAsset; }参数说明pointSize建议取 32、48、64 这类偶数它决定字体在像素网格上的采样点大小太小会让小字号发虚太大又会显著放大图集占用。atlasPadding是字符之间的间距默认值通常不大但我会在含中文字符的项目里给 4 到 6过小会造成字形边缘发毛过大则浪费纹理空间具体看字体抗锯齿效果。multiAtlasTexturesEnabled打开后动态字形可以在图集溢出时创建新图集这个开关在做大型文本时我必开关闭后图集满了就会出现“某几个字符始终缺失”的怪问题。这里有一点需要强调CreateFontAsset(osFont)得到的资产在某些 TMP 版本里默认就是动态模式但我还是在代码里显式调了一次atlasPopulationMode AtlasPopulationMode.Dynamic。显式设置不损失性能却能让行为确定性提高尤其在不同编辑器与运行时版本之间切换时不会因为默认值变化而出现字体资产被静态化的现象。我见过某项目因为没设置该字段在新版编辑器升级后所有动态字体都变成默认字体加一行字段赋值就恢复正常属于典型的“看似玄学、实则缺配置”。3.3 第三步补充一条文件路径路线应付特殊字体按族名创建并不是万能路线。如果需求里明确要从某个文件夹加载用户安装的第三方字体文件、或想自己指定完整的字体路径就得走字体引擎的文件加载接口using UnityEngine.TextCore.LowLevel; public static TMP_FontAsset LoadFontFromSystemFile(string absolutePath, int pointSize 64) { FontEngineError error FontEngine.LoadFontFace(absolutePath, pointSize); if (error ! FontEngineError.Success) { Debug.LogError($[NativeOSFont] LoadFontFace failed, absolutePath{absolutePath}, error{error}); return null; } Font loadedFont FontEngine.GetFont(); if (loadedFont null) return null; TMP_FontAsset tmpAsset TMP_FontAsset.CreateFontAsset(loadedFont); tmpAsset.atlasPopulationMode AtlasPopulationMode.Dynamic; return tmpAsset; }逻辑说明LoadFontFace并不直接返回一个TMP_FontAsset而是先将字体面加载到字体引擎中接着由FontEngine.GetFont()拿到对应的字体对象再交给 TMP 做成动态资产。error返回值是排障的关键别只判断“不等于成功”。下表是我常用的返回码处理思路返回码含义常见原因我的处理路径无效目录拼错或文件名带中文未转义先File.Exists再调用字体格式不支持遇到.ttc集合字体或损坏字体过滤.ttc换单个.ttf/.otf字体面加载失败该字体无对应字重或 API 无权限访问回退到族名创建方案成功正常加载继续创建 TMP 资产在日常流程里文件路径方案我只用于“明确要加载用户目录下某个字体文件”的场景常规系统字体族优先走CreateDynamicFontFromOSFont。补充一个平台细节Windows 的字体目录通常在系统盘下macOS 则分系统目录与用户目录版本差异会导致文件名不一致。所以路径方式永远排在族名方式之后不要上来就拼路径。4. 把动态原生字体接入 UI共享、切换、平台边界三件事4.1 多文本组件共享同一张动态字体避免图集重复膨胀动态字体资产创建成本较高特别是图集纹理的分配。在实际摆放 UI 时千万不要在每一个文本组件上调用一次CreateDynamicFontFromOSFont。正确做法是把资产放进一个进程级缓存public static class NativeOSFontCache { private static readonly Dictionarystring, TMP_FontAsset Cache new(); public static TMP_FontAsset GetOrCreate(string familyName, int pointSize 64) { if (Cache.TryGetValue(familyName, out var cached)) return cached; var fontAsset NativeOSFontProvider.CreateDynamicFontFromOSFont(familyName, pointSize); if (fontAsset null) return null; Cache[familyName] fontAsset; return fontAsset; } }这段代码把“找资产”和“创建资产”合并成一次调用。后续文本组件引用它时所有组件共用同一份图集材质也共享UI 再乱也不会有几十张重复的动态图集。缓存字典的 key 直接使用familyName如果你的项目需要同族字体的多种字重并存就把 key 改成FamilyName|Bold|64这种复合字符串避免不同字重相互覆盖。有一点必须提醒TMP_FontAsset.CreateFontAsset(osFont)生成的资产在运行时注册到 TMP 的字体服务里它不会自动出现在项目管理窗口中。因此它们不参与构建也不随场景持久化。所有引用都必须由这个缓存层负责。如果场景切换时缓存被清理字体应该由某个全局入口重新创建而不是依赖场景里的某个 MonoBehaviour 去隐式创建。4.2 运行时切换系统字体先换 asset 再维护 fallback 列表运行时切换字体的场景在本地化项目中很普遍。做法是先取缓存资产、赋值给目标文本组件再维护 fallback 列表顺序很重要public static void ApplyFont(TMP_Text text, string familyName) { if (text null) return; var target NativeOSFontCache.GetOrCreate(familyName); if (target ! null) { text.font target; if (text.fontFallbacks.Count 0 TMP_Settings.defaultFontAsset ! null) { text.fontFallbacks.Add(TMP_Settings.defaultFontAsset); } text.ForceMeshUpdate(); return; } // 找不到时恢复默认 asset避免空白文本 text.font TMP_Settings.defaultFontAsset; text.ForceMeshUpdate(); }逻辑说明先换主字体资产再补 fallback 列表最后才调ForceMeshUpdate()。这个顺序能确保网格重建时使用的是一套完整的字体解析链。fontFallbacks是文本组件自己的运行时列表如果每个文本都重复添加同一个默认字体会造成列表冗余我习惯在添加前检查是否为空同时尽量把 fallback 配置放在 TMP 设置的全局默认里而不是在代码里逐个文本去 add。切换字体后还要留意行高变化。不同系统字体的升部与降部数值不同同一段文本的高度可能变。如果你在布局里用了ContentSizeFitter切换字体后要等一帧再读尺寸或者主动调一次text.Rebuild(CanvasUpdate.PostLayout)。这在桌面工具中非常常见我踩过不止一次字体一换按钮文字被裁掉半个身位。4.3 平台部署边界同一段代码在编辑器、桌面构建和移动端的不同表现这段值得单独说。字体枚举接口和CreateDynamicFontFromOSFont并不是全平台通用桌面编辑器与独立构建下它们能拿到较完整的系统字体列表在移动端系统字体列表受系统安全限制返回结果可能极简或为空网页端则依赖浏览器提供的字体集合和用户操作系统不完全一致。所以这段代码必须至少包一层平台判断再给出移动端和网页端的回退策略。public static bool IsSystemFontSupported() { #if UNITY_EDITOR || UNITY_STANDALONE return true; #else return false; #endif }我在实际项目里会把“探测系统字体”放在启动阶段的一个专用入口里做结果写入内存缓存而不是等到某个文本初始化时才去查。因为某些平台首次查询字体列表较慢文本组件正在初始化时再去等待枚举结果会造成 UI 跳跃或空白帧。这个“先探测、后使用”的思路也直接影响下一章里那条“编辑器正常、打包后列表为空”的问题。另一个被忽略的边界是服务器或离线渲染环境。没有图形会话、没有用户登录的环境里系统字体会退化为极简集合某些系统甚至只保留点阵字体。如果项目要做在服务器上渲染文本就不要依赖Font.GetOSInstalledFontNames()直接把必要字体作为 unmanaged 资源带进安装包更可靠。5. UnityNativeOSFont 避坑五条让我返工大半天的血泪记录写这部分前先说结论系统字体这条路大部分问题不是接口返回错误而是平台之间行为不一致。把下面的记录当检查清单比临时调试有效得多。5.1 动态字体图集里只有方块没有字形现象系统字体名字找对了动态资产创建成功文本在屏幕上显示的却全是方框或空白。原因字体所在的字形文件格式或字形集与 TMP 的字体引擎不匹配尤其是大字符集字体在默认图集尺寸下装不下或者字体名称对应的是某个特殊字重部分样式在动态模式下不支持直接渲染。解决先把multiAtlasTexturesEnabled打开再把图集宽度拉大最后确认字符码点确实存在于这张系统字体中。选择字体时不要只看族名尝试对目标字符做一次TryAddCharacters探测。探测代码可以写成这样bool ok tmpAsset.TryAddCharacters(中文测试123); Debug.Log($[NativeOSFont] TryAddCharacters{ok});如果返回false说明字形源有问题再去查字体文件格式或更换字体族。这个现象发生时图集参数检查顺序是宽度 → 高度 → padding → multi-atlas → 字体文件本身。5.2 编辑器里正常打包后字体列表为空现象编辑器里字体列表一大堆打包成独立程序后字体枚举结果为空或明显缺失。原因编辑器进程运行在一个带图形界面的桌面环境里字体枚举能力来自当前用户会话而打包后的程序运行在最小化或服务环境时系统可能不加载用户字体甚至某些系统镜像里根本不含目标语言字体。这类问题用常规调试工具看不出报错因为接口本来就不抛异常它只是安静地返回空数组。解决把探测结果做成启动报告至少输出字体总数和第一优先字体是否存在。同时准备一个内置后备字体资产作为兜底。我之前在一台无桌面组件的服务器上跑构建验证结果所有字体的支持字符数都为 0排查到最后才发现是运行环境没有任何用户字体这个坑用普通桌面开发机根本复现不了。5.3 OTF 字体动态加载后图集填充极慢现象加载成功的字体用起来也正常但每输入一句话都会卡一下越生僻的字越卡。原因动态 TMP 的每一个缺失字形都走一次底层字体解析对于复杂轮廓字体字形解析和曲线重建成本更高当字符表没有预热时会表现出明显卡顿。解决在界面初始化时预渲染一批高频字符给字符表热身。如果项目对帧率压力敏感就放弃“全动态”路线改成静态 TMP 资产或二阶段字体加载。我在地图编辑器项目上遇到的实际顿挫是每次打开地名面板几十个生僻地名依次触发图集追加鼠标移动都感觉丢帧。最后是把地名常用的几百字做成独立静态图集动态字体只保留输入框场景问题才缓解。5.4 动态图集越滚越大内存涨到不可收拾现象运行一段时间后内存只增不减动态字体图集数量持续增长。原因动态 TMP 只增加字形很少回收尤其启用了multiAtlasTexturesEnabled后每个新增字形超额时都可能带来新图集而且图集纹理不会因字符不再使用而自动缩小。解决为动态字体限制图集数量上限字重或样式切换时重建资产不要在一个资产里长期堆叠多个语系的字符集。我保留的回退手段是当atlasTextures.Length超过预期值时强制重建动态字体资产并更新所有文本引用。重建过程会短暂让文本恢复默认字体但在长生命周期桌面工具里这比内存泄漏划算得多。5.5 动态字体资产在域重载和场景卸载时被误回收现象场景切换后动态字体变成默认字体或者材质报丢失引用。原因运行时创建的动态字体资产不是项目资产不随场景自动保存如果它只被场景内组件引用场景卸载时引用失效恢复场景时资产又没重新创建。解决用单例或独立管理类持有动态字体资产把创建和销毁收口到应用生命周期层。如果你的项目开启 Domain Reload还需要在重载入口里主动清理缓存字典防止旧资产残留。这个管理类看起来多写几行代码但它同时解决字体丢失和内存泄漏两头问题。6. 进阶验证写一个字体体检工具与 fallback 组合习惯6.1 自动检查字体覆盖率和图集状态的小工具到了最后你会意识到“系统字体可用”不等于“显示正常”。我会在项目启动时跑一个自检脚本遍历目标字体族并输出一份报告验证是否能创建资产、初始字符数、图集张数public class NativeOSFontReporter : MonoBehaviour { private void Awake() { var families NativeOSFontProvider.GetInstalledFontFamilies(); foreach (var family in families) { var asset NativeOSFontProvider.CreateDynamicFontFromOSFont(family, 32); if (asset null) continue; int covered asset.characterTable?.Count ?? 0; Debug.Log($[NativeOSFont] family{family}, assetOktrue, glyphs{covered}, atlasCount{asset.atlasTextures.Length}); } } }这个工具不追求精确覆盖率只快速给出数量级能创建资产、初始字形数量、图集张数。它能在构建机上提前发现“没有中文字体”这类环境问题。6.2 把多个系统字体组合成 fallback 链的验证顺序真正成熟的用法不是单字体而是把若干系统字体串成 fallback 链主字体负责界面高频字符补位字体负责生僻字或特殊语系。验证顺序我建议固定为先在编辑器里手动切换各系统字体看字形和行高是否稳定再用脚本遍历 fallback 链检查缺字上报日志最后在目标平台打一次真机验证。不要跳过中间那步编辑器里的字体族列表和打包机上的字体族列表经常不一致。我保留的固定习惯是“启动自检 缓存 回退”任何设备进入 UI 前先打印字体列表数量随后用缓存加载目标字体找不到就直接切默认字体。这套流程让我再没遇到过因环境字体差异导致的空白文本事故。真相是动态系统字体能帮你省去资源导入的繁琐但没有绝对可靠的字体接口唯一不变的是提前做好环境自检与回退策略。每当项目里又有人说“字体不是问题吧”我都会把这份体检报告甩过去然后补一句先跑一遍巡检再决定要不要用动态系统字体。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进