简介:这是一份面向Unity开发者的开源字体解决方案,专注于在运行时读取操作系统本地字体并应用到TextMeshPro(TMP),解决默认TMP字体库无法覆盖系统全部字体的问题。方案通过C#脚本动态枚举系统字体列表,并利用ShaderLab编写自定义着色器,实现抗锯齿、阴影、描边等渲染优化,适合电子书、教育软件及需要本地化字体的游戏或应用。压缩包共107个文件,包含C#源码、13个Shader、cginc辅助库、TMP预设与材质、示例场景和说明文档,整体仅1.33MB,结构清晰便于集成。目前已有1046人学习下载,适合具备一定Unity和C#基础、希望灵活调用系统字体的开发者。资源内提供可直接运行的示例工程、字体获取脚本及配套Shader,既能参考系统字体枚举与跨平台兼容的处理思路,也能快速融入项目,有效避免字体资源体积膨胀和缺失字体导致的显示异常,实用性较强。1. UnityNativeOSFont:把系统字体变成动态TMP,绕开打包全套字库的笨办法UnityNativeOSFont这个名字听起来像是个偏门工具,但它解决的场景几乎每个做过UI的人都会撞上:界面要用用户本机里安装的字体,项目里却又不想为了几个字符拖进几十MB的字体文件。以前的做法无非两种——要么把中文/日文字体打进包里,要么硬编码一个系统字体名字去猜,猜不中真机就给你满屏方块。UnityNativeOSFont的思路是把操作系统字体这件事抽象成一道明确工序:先枚举系统里的字体文件,再把选中的字体交给TextMeshPro,生成一份动态的TMP_FontAsset。这样一来,中文、日文、韩文、藏文、emoji都能跟着用户系统走,包体不用变大,多语言切换也不用重新打包。适合做跨语言UI工具、桌面应用壳、编辑器扩展,以及那些只需要显示少量动态文字的场景。整套流程的核心就两个点:拿得到字体文件,让TMP认可它。2. 系统字体获取:三平台目录枚举与字体名匹配2.1 为什么不直接用Unity自带的动态字体APIUnity官方给了一个看似够用的接口:Font.CreateDynamicFontFromOSFont(string fontname, int size)。按名字加载系统字体,运行时动态生成字形。但用过的都知道,这个接口有三处硬伤。第一,它不能枚举系统字体。你只能把字体名写进配置里,用户机器上没装这个字体,它也不会给你任何错误提示,只是默默生成一个缺字形的字体,直到界面上出现方框你才反应过来。第二,它对TTC和OTF的支持看平台脸色。Windows下“Microsoft YaHei”能加载,到了Linux同一套配置直接找不到。第三,它在Editor下挺好用,打成WindowsPlayer也基本正常,但Android跟iOS的行为完全不同,移动端这套路径基本废掉。所以做原生字体工具,第一步不是“按名加载”,而是先把字体文件从系统里捞出来。路径有了,名字映射有了,后面无论是生成TMP资产还是动态字体,都有的放矢。这一步绕过去,后续全是玄学。2.2 扫目录而不是调GDI+,三平台字体路径怎么拿有人第一反应是System.Drawing.Text.InstalledFontCollection,能枚举字体名,很省事。但这个类依赖Windows GDI+,在IL2CPP打包下经常被裁剪,而且它拿不到字体文件的具体路径,后续要创建TMP资产时还是得回头找文件。我在一个项目里试过两条路,最后还是回归到最朴素的方案:遍历系统字体目录。using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEngine; public static class NativeFontScanner { // 返回当前系统所有字体文件的绝对路径 public static string[] GetSystemFontPaths() { var dirs = new List<string>(); switch (Application.platform) { case RuntimePlatform.WindowsEditor: case RuntimePlatform.WindowsPlayer: var win = Environment.GetFolderPath(Environment.SpecialFolder.Windows); dirs.Add(Path.Combine(win, "Fonts")); break; case RuntimePlatform.OSXEditor: case RuntimePlatform.OSXPlayer: dirs.Add("/System/Library/Fonts"); dirs.Add("/Library/Fonts"); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); dirs.Add(Path.Combine(home, "Library", "Fonts")); break; case RuntimePlatform.LinuxEditor: case RuntimePlatform.LinuxPlayer: dirs.Add("/usr/share/fonts"); var homeLinux = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); dirs.Add(Path.Combine(homeLinux, ".local", "share", "fonts")); break; default: // 移动端不做目录扫描,回退到项目内置字体目录 dirs.Add(Path.Combine(Application.streamingAssetsPath, "FallbackFonts")); break; } var results = new List<string>(); foreach (var dir in dirs.Where(Directory.Exists)) { // ttf / otf / ttc 都要覆盖,缺一个就少一批中文字体 foreach (var pattern in new[] { "*.ttf", "*.otf", "*.ttc" }) { results.AddRange(Directory.GetFiles(dir, pattern, SearchOption.AllDirectories)); } } return results.Distinct().ToArray(); } }这段代码里的几个细节值得单独说。SearchOption.AllDirectories不是随手写的,Linux发行版经常把字体散落在/usr/share/fonts/truetype这种子目录里,不递归只能拿到一半结果。*.ttc必须加进去,微软雅黑和苹方都是TTC集合字体,漏掉这个后缀,Windows和macOS的主流中文字体一个都扫不到。移动端默认分支回退到StreamingAssets里的FallbackFonts,是因为iOS和Android在文件系统层面没有公开统一的字体目录,硬扫反而容易触发沙盒权限问题。注意:new Font(string path)这个构造在编辑器下可靠,运行时桌面端也能用来探测字体名,但移动端会返回空对象。移动端要拿系统字体,还是直接走Font.CreateDynamicFontFromOSFont。2.3 字体名映射与TTC别名处理拿到路径只是第一阶段,UI层通常按字体显示名来选择。字体文件内部有一个name表,记录着“微软雅黑”“PingFang SC”这些名字,Unity底层会解析。把名字和路径建立索引,后续查找才方便。public static class NativeFontIndex { private static Dictionary<string, string> _index; // TTC 是多个字重的集合,Unity 对它的 name 解析不稳定,需要手动补别名 private static readonly Dictionary<string, string> Aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "Microsoft YaHei", "msyh.ttc" }, { "微软雅黑", "msyh.ttc" }, { "PingFang SC", "PingFang.ttc" }, { "苹方", "PingFang.ttc" } }; public static void Rebuild() { _index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (var path in NativeFontScanner.GetSystemFontPaths()) { var probe = new Font(path); if (probe == null) { continue; } if (!_index.ContainsKey(probe.name)) { _index[probe.name] = path; } UnityEngine.Object.DestroyImmediate(probe); } // 把别名表里常见字体重定向到具体文件 foreach (var kvp in Aliases) { var fullPath = _index.Values.FirstOrDefault(v => v.EndsWith(kvp.Value, StringComparison.OrdinalIgnoreCase)); if (fullPath != null) { _index[kvp.Key] = fullPath; } } } public static string Find(string displayName) { if (_index == null) { Rebuild(); } _index.TryGetValue(displayName, out var path); return path; } }这段代码补充了一个关键细节:new Font(path)对TTC的解析有时拿到的是空字符串,有时候拿到的是“Microsoft YaHei UI”这种带后缀的名字。所以Rebuild时先把真实解析结果放进索引,再用别名表覆盖,把“微软雅黑”和“Microsoft YaHei”这种常见写法统一指向msyh.ttc。DestroyImmediate不能省,否则编辑器里反复刷新索引,内存里会堆一堆临时字体对象。这里还要提一句P/Invoke的话题。有人会去调Windows的DirectWrite或macOS的CoreText,拿到的字体元数据确实丰富,但跨平台编译时要在每个平台写封装,工程成本高。对工具型脚本来说,扫目录加上别名表已经能覆盖95%的需求。3. 动态TMP资产生成:从OS字体到TMP_FontAsset的关键转译3.1 TMP动态字体系统的两层概念TextMeshPro里“动态字体”这个词容易产生歧义,它其实分两层。第一层是UnityEngine.Font.CreateDynamicFontFromOSFont返回的那个Font对象,本质是系统字体句柄,字形获取发生在运行时。第二层是TMP_FontAsset上的动态模式,TMP在需要渲染某个字符时,把缺的字形实时塞进一张动态图集里。UnityNativeOSFont要做的就是把这两层接起来:先拿到OS字体对象,再生成一份可用的TMP_FontAsset,让后者进入“缺字就补”的状态。如果不做第二层转译,直接把系统字体塞给TMP文本组件,TMP会因为找不到图集而报错,或者把所有文字渲染成方框。原因在于TMP的渲染管线依赖TMP_FontAsset.atlasTexture,UnityEngine.Font根本不提供这个属性。所以转译这一步绕不开。TMP_FontAsset本质是ScriptableObject,图集纹理是内部动态创建的。它的工作方式简单来说:文本解析到某个字形,图集里没有,就往图集里写入一个新的字形,并返回图集坐标;图集满了,就按配置扩容或启用多图集。这个机制决定了动态字体非常适合“少量文本、大量不重复字符”的场景,中文用户输入框就是典型例子。3.2 用CreateFontAsset构建动态字体资产TMP_FontAsset的官方入口是静态方法TMP_FontAsset.CreateFontAsset()。不同Unity版本下这个方法的重载差异很大,我习惯用参数完整的那一版,把采样点大小、图集尺寸、Padding这些关键参数显式传进去,避免被默认值坑到。using UnityEngine; using TMPro; public static class DynamicTmpBuilder { // osFont 是上一章从系统拿到的 Font 对象 // assetName 用于在 Editor 下保存 .asset 文件 public static TMP_FontAsset Build(Font osFont, string assetName, int atlasSize = 1024) { var shader = Shader.Find("TextMeshPro/Distance Field"); if (shader == null) { Debug.LogError("找不到 TMP Shader,请先通过 Window > TextMeshPro 导入 Essential Resources"); return null; } var asset = TMP_FontAsset.CreateFontAsset( osFont, shader, 48, // samplingPointSize:采样点大小,决定SDF精度 6, // atlasPadding:字形间距,小了会串色 GlyphRenderMode.SDFAA, atlasSize, // atlasWidth atlasSize, // atlasHeight GlyphRenderMode.SDFAA, true // enableMultiAtlasSupport:允许扩容图集 ); asset.name = assetName; #if UNITY_EDITOR // Editor 下调完参数要落盘,否则每次进编辑器都得重新生成 UnityEditor.AssetDatabase.CreateAsset(asset, $"Assets/Fonts/{assetName}.asset"); UnityEditor.AssetDatabase.SaveAssets(); #endif return asset; } }几个参数值得单独解释。samplingPointSize建议维持48,太小会让笔画细节丢失,小字号显示时边缘发虚;调大能提升还原度,但生成耗时和内存占用都会明显上涨。atlasPadding默认6,中文这种笔画密集的字体建议给到8,否则相邻字形在SDF采样时会互相渗色,渲染出来笔画边缘发黑。atlasSize默认1024,如果动态文本里会飘过大量不同字符,把enableMultiAtlasSupport打开,让它自动扩容到2048或者4096,而不是一开始就分配大图集。AssetDatabase.CreateAsset这行只在Editor下执行,它决定这份TMP_FontAsset是临时对象还是持久化资产。如果你在做编辑器工具,这一步必须保留,否则每次打开项目都得重新生成一遍,性能测试时你会误以为动态字体加载很慢,其实慢的只是重复生成。3.3 多字体Fallback链的配置顺序单份系统字体覆盖不了所有语言。中文字体再大,碰到日文片假名、韩文谚文、特殊符号时还是会缺字。TMP的解决方案是fallbackFontAssetTable,按数组顺序逐级查找字形。主字体缺字符时,TMP会去查fallback链里的下一份字体,找到就用,找不到继续往下走,直到末尾。public static void SetupFallback(TMP_FontAsset main, params TMP_FontAsset[] companions) { if (main.fallbackFontAssetTable == null) { main.fallbackFontAssetTable = new System.Collections.Generic.List<TMP_FontAsset>(); } else { main.fallbackFontAssetTable.Clear(); } main.fallbackFontAssetTable.AddRange(companions); }这条链的顺序有讲究,不是随便排的。第一顺位放覆盖语言最全的字体,第二顺位放符号类字体。如果你把emoji字体放第一个,每个汉字都会先去emoji字体里兜一圈,命中率低还拖慢网格更新速度。我的习惯是中文系统字体放main,日文、韩文这些language-specific字体放companions,最后再挂一个Icon字体收尾。这样处理的好处是,日语文本里的汉字先命中中文字体,片假名再回落日文字体,减少Fallback链上的无效查询。另外要注意:Fallback链上的字体资产最好是静态字体。动态字体做Fallback时会额外开辟图集,一个主字体挂三份动态Fallback字体,内存按倍数涨。静态字体虽然包体大一点,但在Fallback链上更可控。4. ShaderLab适配:动态字体的SDF采样与材质联动4.1 TMP内置Shader为什么要用SDFTMP的动态字体图集不是普通RGBA贴图,而是SDF(Signed Distance Field)格式。每个字形在纹理里保存的是距离场数据,而不是颜色值。渲染时通过Shader重建边缘,这样图集缩放到任意尺寸,笔画都能保持平滑边缘,避免普通位图字体的锯齿和模糊。这个设计对动态字体特别重要,因为动态图集是运行时生成的,不可能像静态字体那样预烘焙最佳像素。SDF让动态图集在不同字号下都能渲染出可接受的边缘质量,代价是Shader里要多写一段距离场到Alpha的转换逻辑。TMP内置的TextMeshPro/Distance FieldShader就是干这个的。4.2 抽一份能跑的ShaderLab基础配置直接改TMP内置Shader不推荐,升级TMP时会被覆盖。我一般复制一份再改,把最基础的结构拆出来,关键配置长这样。Shader "Custom/OSFontSDF" { Properties { _FaceColor ("Face Color", Color) = (1,1,1,1) _FaceDilate ("Face Dilate", Range(-1,1)) = 0 _OutlineColor ("Outline Color", Color) = (0,0,0,1) _OutlineWidth ("Outline Width", Range(0,1)) = 0 _UnderlayColor ("Border Color", Color) = (0,0,0,0.5) _UnderlayOffsetX ("Border OffsetX", Range(-1,1)) = 0 _UnderlayOffsetY ("Border OffsetY", Range(-1,1)) = 0 _Texture ("Font Atlas", 2D) = "white" {} } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Stencil { Ref 1 Comp Equal Pass Keep } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _Texture; fixed4 _FaceColor; float _FaceDilate; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.color = v.color; return o; } fixed4 frag(v2f i) : SV_Target { float dist = tex2D(_Texture, i.uv).a - _FaceDilate; float alpha = smoothstep(0.5 - 0.1, 0.5 + 0.1, dist); return fixed4(_FaceColor.rgb * i.color.rgb, alpha * i.color.a); } ENDCG } } }这段ShaderLab里真正跟动态字体强相关的是两块:Stencil和距离场采样。Stencil块负责UI系统的裁剪,TMP默认Shader用Ref 1和Comp Equal,Mask组件依赖这个写入规则来裁剪滚动列表里的文字。删掉这块,文字放进ScrollRect就会穿帮。片段着色器里的smoothstep(0.5 - 0.1, 0.5 + 0.1, dist)是把SDF距离值转成边缘Alpha的核心,0.1的宽度决定字形边缘是否发虚。这个简化版只是演示SDF的接线逻辑,真的生产环境里建议还是以TMP内置Shader为模板改参数,不要直接用这份代码做渲染,它缺少描边、阴影这些都内建特效的支持。4.3 图集重建事件的材质联动坑动态字体图集在运行时会因为新字符加入而扩容,扩容意味着纹理被替换。之前绑定在材质上的_Texture引用会失效,如果不刷新,渲染出来的文字会像“隔了一层的旧图集”。TMP为这个场景提供了事件回调:OnFontTextureChanged。fontAsset.OnFontTextureChanged += HandleAtlasChanged; void HandleAtlasChanged(TMP_FontAsset asset) { // 动态图集扩容后,所有使用该字体的材质都要刷新主纹理 var renderers = FindObjectsByType<MeshRenderer>(FindObjectsSortMode.None); foreach (var r in renderers) { if (r.sharedMaterial != null && r.sharedMaterial.mainTexture == null) { r.sharedMaterial.mainTexture = asset.atlasTexture; } } }这里需要说明边界。上面的代码只是最小演示,真的项目里不要让每个MeshRenderer去轮询,应该由TMP的字体管理器统一派发材质刷新。另外,mainTexture == null这个条件有点粗糙,一个材质如果被其他字体复用,这里也会被误刷。我一般会在字体asset上挂一个绑定的材质列表,专门指向使用它的材质对象,只有列表里的材质才刷新,避免误伤。这个事件在文本网格生成时触发,一条消息成本不高。但如果你在Update里每帧创建新TMP文本、每帧触发字体图集扩容,事件风暴会把帧率拖下来。正确做法是文本预生成或者限制动态字体仅用于用户输入内容。5. 常见问题与避坑:动态字体上线前要过的五道坎5.1 中文文本渲染成空白现象:TMP文本挂了动态字体资产,文字完全不显示,连方块都没有。原因:创建TMP_FontAsset时传入的Shader不是TMP专用Shader。TMP在内部要读取SDF图集的贴图参数,如果Shader是UI/Default这种普通UI Shader,它不包含_Texture与SDF采样逻辑,TMP的材质系统直接放弃渲染。另一种常见情况是Shader.Find("TextMeshPro/Distance Field")返回null,说明TMP的Essential Resources还没导入,创建出来的资产是空壳。解决:先通过Window > TextMeshPro导入Essential Resources,再用Shader.Find("TextMeshPro/Distance Field")重建资产。扔进场景前,用Debug.Log(asset.atlasTexture != null)检查图集是否有效,不是就重来。这算是动态字体最常见的开局翻车,十个里面八个是这一步。5.2 TTC字体名字对不上现象:Windows下扫到msyh.ttc,new Font(path)返回的名字是空字符串,按“Microsoft YaHei”检索死活找不到。原因:TTC是TrueType Collection,一个文件里塞了多个字重。Unity的new Font(path)对TTC的name解析历来不稳定,有时候能读出“Microsoft YaHei UI”,有时候读出来是空串,全靠系统字体缓存的心情。解决:在建索引时不要只依赖new Font(path).name,额外挂一张别名表。把msyh.ttc的常见名“Microsoft YaHei”“微软雅黑”手动映射到该路径,命中TTC文件时优先查别名表,查不到再走Font.CreateDynamicFontFromOSFont按系统名加载。这个血泪经验在处理中文系统字体时几乎是必中的,别名表建议在工具里固定维护,而不是每次运行时去猜。5.3 编辑器正常,桌面Player全变方块现象:WindowsPlayer打包出来后,中文字体全部显示为方块,但编辑器里跑得好好的。原因:桌面Player的沙箱权限和字体路径与编辑器不同。用Environment.GetFolderPath拿字体目录,在Player下可能被系统重定向,扫描结果为空。Fallback链上什么都没配,自然所有字符都缺字形。解决:Player下先输出扫描到的路径数量和Failback链长度,确认字形文件拿到了。如果目录扫描失败,改用注册表键SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts作为备选来源,这个键记录了字体名和文件名映射,比目录扫描可靠得多。移动端遇到同类问题时,直接切到Font.CreateDynamicFontFromOSFont,不要依赖文件路径。5.4 动态图集自动扩容后内存失控现象:长文本滚动页面,跑几轮之后动态字体的图集纹理从1024涨到4096,内存占用超预期。原因:动态字体每遇到一个新字符就往图集里补字形。中文字表量大,一个滚动列表翻完能带走几千个字符。如果atlasPopulationMode保持在SDFAA,每个字形还要预留Padding,图集翻倍速度远比你想象得快。解决:给atlasWidth和atlasHeight设上限,超过上限后不再扩图集,缺字时显示替换字形。再把固定不变的文案转成静态字体资产,动态字体只留给用户输入内容。多语言切换场景用LRU缓存定期卸载不用的字体资产,避免所有字体同时常驻内存。另外一个实用习惯是,在Profiler里跑一次滚动列表,看内存增量是否可接受,再决定要不要加缓存策略。5.5 自定义Shader在Mask下文字消失现象:把自定义SDF Shader套上去后,放在ScrollRect里的文本进入裁剪区外就消失,但普通Canvas文本显示正常。原因:Mask组件依赖Stencil的Ref和Comp操作,自定义Shader没写Stencil块或Ref对不上。TMP默认Shader的Stencil Ref是1,Comp是Equal,如果不一致,Mask的写入操作无法识别这个字体。解决:把Stencil块补上,Ref 1,Comp Equal,Pass Keep。还要确认Tags里没有配置成其他渲染队列,RenderType保持Transparent,Queue保持Transparent,避免被阴影投射拾取。每次改完Shader,用同一场景的Mask区域做回归测试,滚动列表里文字进进出出不闪断才算过。6. 验证方法:性能基线与多语言缓存技巧6.1 用Profiler把动态字体开销测成数字动态字体看着方便,上线前得知道它真实的代价。我一般用一段脚本跑出内存增量,再决定要不要让这个功能进主界面。public IEnumerator RunBaseline() { const int sampleCount = 100; var texts = new TMP_Text[sampleCount]; for (var i = 0; i < sampleCount; i++) { texts[i] = CreateOneText(i.ToString()); } yield return null; var before = Profiler.GetTotalAllocatedMemoryLong(); for (var i = 0; i < sampleCount; i++) { texts[i].text = "包含中英文与符号的测试文本" + i; texts[i].ForceMeshUpdate(); } yield return null; var after = Profiler.GetTotalAllocatedMemoryLong(); Debug.Log($"[动态字体] 内存增量 {(after - before) / (1024f * 1024f):F2} MB"); }ForceMeshUpdate是必须的,它强制TMP立即刷新网格,动态字体的字形生成在这个节点发生。如果只改.text不强制刷新,内存增量数据会偏小,测出来全是假象。6.2 多语言切换的缓存策略每次切换语言,不要销毁字体资产。TMP的图集里已经缓存了上次用到的字形,切回来不需要重新生成,直接复用图集坐标就行。长期不用的资产才需要主动卸载。我自己用引用计数做淘汰:每份字体资产记录被哪些界面引用,引用归零时延迟一帧释放,避免频繁创建销毁造成内存抖动。这个坑我踩过一次之后,每次处理动态字体需求,都强制走一遍上面的基线脚本,再把结果跟静态字体方案放在一起对比。凡是动态字体内存增量超过预期,就直接降级方案。原生字体这事,系统、权限、字体格式纠缠在一起,能把关键指标量化出来,至少不会被线上突发的内存问题打得措手不及。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一份面向Unity开发者的开源字体解决方案,专注于在运行时读取操作系统本地字体并应用到TextMeshPro(TMP),解决默认TMP字体库无法覆盖系统全部字体的问题。方案通过C#脚本动态枚举系统字体列表,并利用ShaderLab编写自定义着色器,实现抗锯齿、阴影、描边等渲染优化,适合电子书、教育软件及需要本地化字体的游戏或应用。压缩包共107个文件,包含C#源码、13个Shader、cginc辅助库、TMP预设与材质、示例场景和说明文档,整体仅1.33MB,结构清晰便于集成。目前已有1046人学习下载,适合具备一定Unity和C#基础、希望灵活调用系统字体的开发者。资源内提供可直接运行的示例工程、字体获取脚本及配套Shader,既能参考系统字体枚举与跨平台兼容的处理思路,也能快速融入项目,有效避免字体资源体积膨胀和缺失字体导致的显示异常,实用性较强。1. UnityNativeOSFont:把系统字体变成动态TMP,绕开打包全套字库的笨办法UnityNativeOSFont这个名字听起来像是个偏门工具,但它解决的场景几乎每个做过UI的人都会撞上:界面要用用户本机里安装的字体,项目里却又不想为了几个字符拖进几十MB的字体文件。以前的做法无非两种——要么把中文/日文字体打进包里,要么硬编码一个系统字体名字去猜,猜不中真机就给你满屏方块。UnityNativeOSFont的思路是把操作系统字体这件事抽象成一道明确工序:先枚举系统里的字体文件,再把选中的字体交给TextMeshPro,生成一份动态的TMP_FontAsset。这样一来,中文、日文、韩文、藏文、emoji都能跟着用户系统走,包体不用变大,多语言切换也不用重新打包。适合做跨语言UI工具、桌面应用壳、编辑器扩展,以及那些只需要显示少量动态文字的场景。整套流程的核心就两个点:拿得到字体文件,让TMP认可它。2. 系统字体获取:三平台目录枚举与字体名匹配2.1 为什么不直接用Unity自带的动态字体APIUnity官方给了一个看似够用的接口:Font.CreateDynamicFontFromOSFont(string fontname, int size)。按名字加载系统字体,运行时动态生成字形。但用过的都知道,这个接口有三处硬伤。第一,它不能枚举系统字体。你只能把字体名写进配置里,用户机器上没装这个字体,它也不会给你任何错误提示,只是默默生成一个缺字形的字体,直到界面上出现方框你才反应过来。第二,它对TTC和OTF的支持看平台脸色。Windows下“Microsoft YaHei”能加载,到了Linux同一套配置直接找不到。第三,它在Editor下挺好用,打成WindowsPlayer也基本正常,但Android跟iOS的行为完全不同,移动端这套路径基本废掉。所以做原生字体工具,第一步不是“按名加载”,而是先把字体文件从系统里捞出来。路径有了,名字映射有了,后面无论是生成TMP资产还是动态字体,都有的放矢。这一步绕过去,后续全是玄学。2.2 扫目录而不是调GDI+,三平台字体路径怎么拿有人第一反应是System.Drawing.Text.InstalledFontCollection,能枚举字体名,很省事。但这个类依赖Windows GDI+,在IL2CPP打包下经常被裁剪,而且它拿不到字体文件的具体路径,后续要创建TMP资产时还是得回头找文件。我在一个项目里试过两条路,最后还是回归到最朴素的方案:遍历系统字体目录。using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEngine; public static class NativeFontScanner { // 返回当前系统所有字体文件的绝对路径 public static string[] GetSystemFontPaths() { var dirs = new List<string>(); switch (Application.platform) { case RuntimePlatform.WindowsEditor: case RuntimePlatform.WindowsPlayer: var win = Environment.GetFolderPath(Environment.SpecialFolder.Windows); dirs.Add(Path.Combine(win, "Fonts")); break; case RuntimePlatform.OSXEditor: case RuntimePlatform.OSXPlayer: dirs.Add("/System/Library/Fonts"); dirs.Add("/Library/Fonts"); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); dirs.Add(Path.Combine(home, "Library", "Fonts")); break; case RuntimePlatform.LinuxEditor: case RuntimePlatform.LinuxPlayer: dirs.Add("/usr/share/fonts"); var homeLinux = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); dirs.Add(Path.Combine(homeLinux, ".local", "share", "fonts")); break; default: // 移动端不做目录扫描,回退到项目内置字体目录 dirs.Add(Path.Combine(Application.streamingAssetsPath, "FallbackFonts")); break; } var results = new List<string>(); foreach (var dir in dirs.Where(Directory.Exists)) { // ttf / otf / ttc 都要覆盖,缺一个就少一批中文字体 foreach (var pattern in new[] { "*.ttf", "*.otf", "*.ttc" }) { results.AddRange(Directory.GetFiles(dir, pattern, SearchOption.AllDirectories)); } } return results.Distinct().ToArray(); } }这段代码里的几个细节值得单独说。SearchOption.AllDirectories不是随手写的,Linux发行版经常把字体散落在/usr/share/fonts/truetype这种子目录里,不递归只能拿到一半结果。*.ttc必须加进去,微软雅黑和苹方都是TTC集合字体,漏掉这个后缀,Windows和macOS的主流中文字体一个都扫不到。移动端默认分支回退到StreamingAssets里的FallbackFonts,是因为iOS和Android在文件系统层面没有公开统一的字体目录,硬扫反而容易触发沙盒权限问题。注意:new Font(string path)这个构造在编辑器下可靠,运行时桌面端也能用来探测字体名,但移动端会返回空对象。移动端要拿系统字体,还是直接走Font.CreateDynamicFontFromOSFont。2.3 字体名映射与TTC别名处理拿到路径只是第一阶段,UI层通常按字体显示名来选择。字体文件内部有一个name表,记录着“微软雅黑”“PingFang SC”这些名字,Unity底层会解析。把名字和路径建立索引,后续查找才方便。public static class NativeFontIndex { private static Dictionary<string, string> _index; // TTC 是多个字重的集合,Unity 对它的 name 解析不稳定,需要手动补别名 private static readonly Dictionary<string, string> Aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "Microsoft YaHei", "msyh.ttc" }, { "微软雅黑", "msyh.ttc" }, { "PingFang SC", "PingFang.ttc" }, { "苹方", "PingFang.ttc" } }; public static void Rebuild() { _index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (var path in NativeFontScanner.GetSystemFontPaths()) { var probe = new Font(path); if (probe == null) { continue; } if (!_index.ContainsKey(probe.name)) { _index[probe.name] = path; } UnityEngine.Object.DestroyImmediate(probe); } // 把别名表里常见字体重定向到具体文件 foreach (var kvp in Aliases) { var fullPath = _index.Values.FirstOrDefault(v => v.EndsWith(kvp.Value, StringComparison.OrdinalIgnoreCase)); if (fullPath != null) { _index[kvp.Key] = fullPath; } } } public static string Find(string displayName) { if (_index == null) { Rebuild(); } _index.TryGetValue(displayName, out var path); return path; } }这段代码补充了一个关键细节:new Font(path)对TTC的解析有时拿到的是空字符串,有时候拿到的是“Microsoft YaHei UI”这种带后缀的名字。所以Rebuild时先把真实解析结果放进索引,再用别名表覆盖,把“微软雅黑”和“Microsoft YaHei”这种常见写法统一指向msyh.ttc。DestroyImmediate不能省,否则编辑器里反复刷新索引,内存里会堆一堆临时字体对象。这里还要提一句P/Invoke的话题。有人会去调Windows的DirectWrite或macOS的CoreText,拿到的字体元数据确实丰富,但跨平台编译时要在每个平台写封装,工程成本高。对工具型脚本来说,扫目录加上别名表已经能覆盖95%的需求。3. 动态TMP资产生成:从OS字体到TMP_FontAsset的关键转译3.1 TMP动态字体系统的两层概念TextMeshPro里“动态字体”这个词容易产生歧义,它其实分两层。第一层是UnityEngine.Font.CreateDynamicFontFromOSFont返回的那个Font对象,本质是系统字体句柄,字形获取发生在运行时。第二层是TMP_FontAsset上的动态模式,TMP在需要渲染某个字符时,把缺的字形实时塞进一张动态图集里。UnityNativeOSFont要做的就是把这两层接起来:先拿到OS字体对象,再生成一份可用的TMP_FontAsset,让后者进入“缺字就补”的状态。如果不做第二层转译,直接把系统字体塞给TMP文本组件,TMP会因为找不到图集而报错,或者把所有文字渲染成方框。原因在于TMP的渲染管线依赖TMP_FontAsset.atlasTexture,UnityEngine.Font根本不提供这个属性。所以转译这一步绕不开。TMP_FontAsset本质是ScriptableObject,图集纹理是内部动态创建的。它的工作方式简单来说:文本解析到某个字形,图集里没有,就往图集里写入一个新的字形,并返回图集坐标;图集满了,就按配置扩容或启用多图集。这个机制决定了动态字体非常适合“少量文本、大量不重复字符”的场景,中文用户输入框就是典型例子。3.2 用CreateFontAsset构建动态字体资产TMP_FontAsset的官方入口是静态方法TMP_FontAsset.CreateFontAsset()。不同Unity版本下这个方法的重载差异很大,我习惯用参数完整的那一版,把采样点大小、图集尺寸、Padding这些关键参数显式传进去,避免被默认值坑到。using UnityEngine; using TMPro; public static class DynamicTmpBuilder { // osFont 是上一章从系统拿到的 Font 对象 // assetName 用于在 Editor 下保存 .asset 文件 public static TMP_FontAsset Build(Font osFont, string assetName, int atlasSize = 1024) { var shader = Shader.Find("TextMeshPro/Distance Field"); if (shader == null) { Debug.LogError("找不到 TMP Shader,请先通过 Window > TextMeshPro 导入 Essential Resources"); return null; } var asset = TMP_FontAsset.CreateFontAsset( osFont, shader, 48, // samplingPointSize:采样点大小,决定SDF精度 6, // atlasPadding:字形间距,小了会串色 GlyphRenderMode.SDFAA, atlasSize, // atlasWidth atlasSize, // atlasHeight GlyphRenderMode.SDFAA, true // enableMultiAtlasSupport:允许扩容图集 ); asset.name = assetName; #if UNITY_EDITOR // Editor 下调完参数要落盘,否则每次进编辑器都得重新生成 UnityEditor.AssetDatabase.CreateAsset(asset, $"Assets/Fonts/{assetName}.asset"); UnityEditor.AssetDatabase.SaveAssets(); #endif return asset; } }几个参数值得单独解释。samplingPointSize建议维持48,太小会让笔画细节丢失,小字号显示时边缘发虚;调大能提升还原度,但生成耗时和内存占用都会明显上涨。atlasPadding默认6,中文这种笔画密集的字体建议给到8,否则相邻字形在SDF采样时会互相渗色,渲染出来笔画边缘发黑。atlasSize默认1024,如果动态文本里会飘过大量不同字符,把enableMultiAtlasSupport打开,让它自动扩容到2048或者4096,而不是一开始就分配大图集。AssetDatabase.CreateAsset这行只在Editor下执行,它决定这份TMP_FontAsset是临时对象还是持久化资产。如果你在做编辑器工具,这一步必须保留,否则每次打开项目都得重新生成一遍,性能测试时你会误以为动态字体加载很慢,其实慢的只是重复生成。3.3 多字体Fallback链的配置顺序单份系统字体覆盖不了所有语言。中文字体再大,碰到日文片假名、韩文谚文、特殊符号时还是会缺字。TMP的解决方案是fallbackFontAssetTable,按数组顺序逐级查找字形。主字体缺字符时,TMP会去查fallback链里的下一份字体,找到就用,找不到继续往下走,直到末尾。public static void SetupFallback(TMP_FontAsset main, params TMP_FontAsset[] companions) { if (main.fallbackFontAssetTable == null) { main.fallbackFontAssetTable = new System.Collections.Generic.List<TMP_FontAsset>(); } else { main.fallbackFontAssetTable.Clear(); } main.fallbackFontAssetTable.AddRange(companions); }这条链的顺序有讲究,不是随便排的。第一顺位放覆盖语言最全的字体,第二顺位放符号类字体。如果你把emoji字体放第一个,每个汉字都会先去emoji字体里兜一圈,命中率低还拖慢网格更新速度。我的习惯是中文系统字体放main,日文、韩文这些language-specific字体放companions,最后再挂一个Icon字体收尾。这样处理的好处是,日语文本里的汉字先命中中文字体,片假名再回落日文字体,减少Fallback链上的无效查询。另外要注意:Fallback链上的字体资产最好是静态字体。动态字体做Fallback时会额外开辟图集,一个主字体挂三份动态Fallback字体,内存按倍数涨。静态字体虽然包体大一点,但在Fallback链上更可控。4. ShaderLab适配:动态字体的SDF采样与材质联动4.1 TMP内置Shader为什么要用SDFTMP的动态字体图集不是普通RGBA贴图,而是SDF(Signed Distance Field)格式。每个字形在纹理里保存的是距离场数据,而不是颜色值。渲染时通过Shader重建边缘,这样图集缩放到任意尺寸,笔画都能保持平滑边缘,避免普通位图字体的锯齿和模糊。这个设计对动态字体特别重要,因为动态图集是运行时生成的,不可能像静态字体那样预烘焙最佳像素。SDF让动态图集在不同字号下都能渲染出可接受的边缘质量,代价是Shader里要多写一段距离场到Alpha的转换逻辑。TMP内置的TextMeshPro/Distance FieldShader就是干这个的。4.2 抽一份能跑的ShaderLab基础配置直接改TMP内置Shader不推荐,升级TMP时会被覆盖。我一般复制一份再改,把最基础的结构拆出来,关键配置长这样。Shader "Custom/OSFontSDF" { Properties { _FaceColor ("Face Color", Color) = (1,1,1,1) _FaceDilate ("Face Dilate", Range(-1,1)) = 0 _OutlineColor ("Outline Color", Color) = (0,0,0,1) _OutlineWidth ("Outline Width", Range(0,1)) = 0 _UnderlayColor ("Border Color", Color) = (0,0,0,0.5) _UnderlayOffsetX ("Border OffsetX", Range(-1,1)) = 0 _UnderlayOffsetY ("Border OffsetY", Range(-1,1)) = 0 _Texture ("Font Atlas", 2D) = "white" {} } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Stencil { Ref 1 Comp Equal Pass Keep } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _Texture; fixed4 _FaceColor; float _FaceDilate; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.color = v.color; return o; } fixed4 frag(v2f i) : SV_Target { float dist = tex2D(_Texture, i.uv).a - _FaceDilate; float alpha = smoothstep(0.5 - 0.1, 0.5 + 0.1, dist); return fixed4(_FaceColor.rgb * i.color.rgb, alpha * i.color.a); } ENDCG } } }这段ShaderLab里真正跟动态字体强相关的是两块:Stencil和距离场采样。Stencil块负责UI系统的裁剪,TMP默认Shader用Ref 1和Comp Equal,Mask组件依赖这个写入规则来裁剪滚动列表里的文字。删掉这块,文字放进ScrollRect就会穿帮。片段着色器里的smoothstep(0.5 - 0.1, 0.5 + 0.1, dist)是把SDF距离值转成边缘Alpha的核心,0.1的宽度决定字形边缘是否发虚。这个简化版只是演示SDF的接线逻辑,真的生产环境里建议还是以TMP内置Shader为模板改参数,不要直接用这份代码做渲染,它缺少描边、阴影这些都内建特效的支持。4.3 图集重建事件的材质联动坑动态字体图集在运行时会因为新字符加入而扩容,扩容意味着纹理被替换。之前绑定在材质上的_Texture引用会失效,如果不刷新,渲染出来的文字会像“隔了一层的旧图集”。TMP为这个场景提供了事件回调:OnFontTextureChanged。fontAsset.OnFontTextureChanged += HandleAtlasChanged; void HandleAtlasChanged(TMP_FontAsset asset) { // 动态图集扩容后,所有使用该字体的材质都要刷新主纹理 var renderers = FindObjectsByType<MeshRenderer>(FindObjectsSortMode.None); foreach (var r in renderers) { if (r.sharedMaterial != null && r.sharedMaterial.mainTexture == null) { r.sharedMaterial.mainTexture = asset.atlasTexture; } } }这里需要说明边界。上面的代码只是最小演示,真的项目里不要让每个MeshRenderer去轮询,应该由TMP的字体管理器统一派发材质刷新。另外,mainTexture == null这个条件有点粗糙,一个材质如果被其他字体复用,这里也会被误刷。我一般会在字体asset上挂一个绑定的材质列表,专门指向使用它的材质对象,只有列表里的材质才刷新,避免误伤。这个事件在文本网格生成时触发,一条消息成本不高。但如果你在Update里每帧创建新TMP文本、每帧触发字体图集扩容,事件风暴会把帧率拖下来。正确做法是文本预生成或者限制动态字体仅用于用户输入内容。5. 常见问题与避坑:动态字体上线前要过的五道坎5.1 中文文本渲染成空白现象:TMP文本挂了动态字体资产,文字完全不显示,连方块都没有。原因:创建TMP_FontAsset时传入的Shader不是TMP专用Shader。TMP在内部要读取SDF图集的贴图参数,如果Shader是UI/Default这种普通UI Shader,它不包含_Texture与SDF采样逻辑,TMP的材质系统直接放弃渲染。另一种常见情况是Shader.Find("TextMeshPro/Distance Field")返回null,说明TMP的Essential Resources还没导入,创建出来的资产是空壳。解决:先通过Window > TextMeshPro导入Essential Resources,再用Shader.Find("TextMeshPro/Distance Field")重建资产。扔进场景前,用Debug.Log(asset.atlasTexture != null)检查图集是否有效,不是就重来。这算是动态字体最常见的开局翻车,十个里面八个是这一步。5.2 TTC字体名字对不上现象:Windows下扫到msyh.ttc,new Font(path)返回的名字是空字符串,按“Microsoft YaHei”检索死活找不到。原因:TTC是TrueType Collection,一个文件里塞了多个字重。Unity的new Font(path)对TTC的name解析历来不稳定,有时候能读出“Microsoft YaHei UI”,有时候读出来是空串,全靠系统字体缓存的心情。解决:在建索引时不要只依赖new Font(path).name,额外挂一张别名表。把msyh.ttc的常见名“Microsoft YaHei”“微软雅黑”手动映射到该路径,命中TTC文件时优先查别名表,查不到再走Font.CreateDynamicFontFromOSFont按系统名加载。这个血泪经验在处理中文系统字体时几乎是必中的,别名表建议在工具里固定维护,而不是每次运行时去猜。5.3 编辑器正常,桌面Player全变方块现象:WindowsPlayer打包出来后,中文字体全部显示为方块,但编辑器里跑得好好的。原因:桌面Player的沙箱权限和字体路径与编辑器不同。用Environment.GetFolderPath拿字体目录,在Player下可能被系统重定向,扫描结果为空。Fallback链上什么都没配,自然所有字符都缺字形。解决:Player下先输出扫描到的路径数量和Failback链长度,确认字形文件拿到了。如果目录扫描失败,改用注册表键SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts作为备选来源,这个键记录了字体名和文件名映射,比目录扫描可靠得多。移动端遇到同类问题时,直接切到Font.CreateDynamicFontFromOSFont,不要依赖文件路径。5.4 动态图集自动扩容后内存失控现象:长文本滚动页面,跑几轮之后动态字体的图集纹理从1024涨到4096,内存占用超预期。原因:动态字体每遇到一个新字符就往图集里补字形。中文字表量大,一个滚动列表翻完能带走几千个字符。如果atlasPopulationMode保持在SDFAA,每个字形还要预留Padding,图集翻倍速度远比你想象得快。解决:给atlasWidth和atlasHeight设上限,超过上限后不再扩图集,缺字时显示替换字形。再把固定不变的文案转成静态字体资产,动态字体只留给用户输入内容。多语言切换场景用LRU缓存定期卸载不用的字体资产,避免所有字体同时常驻内存。另外一个实用习惯是,在Profiler里跑一次滚动列表,看内存增量是否可接受,再决定要不要加缓存策略。5.5 自定义Shader在Mask下文字消失现象:把自定义SDF Shader套上去后,放在ScrollRect里的文本进入裁剪区外就消失,但普通Canvas文本显示正常。原因:Mask组件依赖Stencil的Ref和Comp操作,自定义Shader没写Stencil块或Ref对不上。TMP默认Shader的Stencil Ref是1,Comp是Equal,如果不一致,Mask的写入操作无法识别这个字体。解决:把Stencil块补上,Ref 1,Comp Equal,Pass Keep。还要确认Tags里没有配置成其他渲染队列,RenderType保持Transparent,Queue保持Transparent,避免被阴影投射拾取。每次改完Shader,用同一场景的Mask区域做回归测试,滚动列表里文字进进出出不闪断才算过。6. 验证方法:性能基线与多语言缓存技巧6.1 用Profiler把动态字体开销测成数字动态字体看着方便,上线前得知道它真实的代价。我一般用一段脚本跑出内存增量,再决定要不要让这个功能进主界面。public IEnumerator RunBaseline() { const int sampleCount = 100; var texts = new TMP_Text[sampleCount]; for (var i = 0; i < sampleCount; i++) { texts[i] = CreateOneText(i.ToString()); } yield return null; var before = Profiler.GetTotalAllocatedMemoryLong(); for (var i = 0; i < sampleCount; i++) { texts[i].text = "包含中英文与符号的测试文本" + i; texts[i].ForceMeshUpdate(); } yield return null; var after = Profiler.GetTotalAllocatedMemoryLong(); Debug.Log($"[动态字体] 内存增量 {(after - before) / (1024f * 1024f):F2} MB"); }ForceMeshUpdate是必须的,它强制TMP立即刷新网格,动态字体的字形生成在这个节点发生。如果只改.text不强制刷新,内存增量数据会偏小,测出来全是假象。6.2 多语言切换的缓存策略每次切换语言,不要销毁字体资产。TMP的图集里已经缓存了上次用到的字形,切回来不需要重新生成,直接复用图集坐标就行。长期不用的资产才需要主动卸载。我自己用引用计数做淘汰:每份字体资产记录被哪些界面引用,引用归零时延迟一帧释放,避免频繁创建销毁造成内存抖动。这个坑我踩过一次之后,每次处理动态字体需求,都强制走一遍上面的基线脚本,再把结果跟静态字体方案放在一起对比。凡是动态字体内存增量超过预期,就直接降级方案。原生字体这事,系统、权限、字体格式纠缠在一起,能把关键指标量化出来,至少不会被线上突发的内存问题打得措手不及。希望帮到你。本文还有配套的精品资源,点击获取