做游戏出海,中日韩市场几乎是兵家必争之地。我最近在一款休闲游戏的多语言改造中,被日文字体折腾得够呛——不是没有字体,而是Unity里TextMeshPro(TMP)配合AssetBundle做动态切换时,一堆时序、引用和内存问题轮着来。这篇文章把最终跑通的方案完整记录一下,重点是中日文字体切换的实现思路、TMP动态字体的配置细节,以及AssetBundle资源管理的若干避坑经验。
先说结论:这套方案的核心是“语言包独立成Bundle、字体Asset动态创建、全局替换管理器统一接管”,适合需要热更、包体敏感、有多语言切换需求的Unity项目。如果你正准备做海外本地化,或者已经踩了字体不显示、切换后UI花掉、内存暴涨这些坑,这篇文章应该能帮你省下不少时间。
1. 背景与方案选型:中日文切换为什么是硬骨头
1.1 中日文不能共用一个字体的三个原因
很多项目在最开始只有中文,做日文时第一反应是“都是汉字,直接复用中文字体不就行了”。实际一跑就发现完全不是那么回事。
第一,字形差异。中文用简体汉字,日文用的汉字部分有自己独立的字形标准(即日本工业标准的汉字字形),比如“骨”“直”“曜”这些字,中文字体和日文字体的笔画、结构都不一样。如果你拿中文字体直接渲染日文文本,玩家一眼就能看出“这游戏是机翻的”,而且部分日本用户会直接打差评。反过来,日文字体渲染简体中文更是缺字严重。
第二,字符覆盖范围不同。日文除了汉字,还有平假名和片假名两套音节文字,这两套字符在中文字体里根本没有。就算主字体是中文字体,遇到假名也只能显示成方块。所以严格来说,中日文需要两套独立的字体资源,不能共用一套。
第三,包体问题。中文全字符集大概有两万多个常用汉字,如果把这些字形全部打进最终包体,字体文件动辄20MB到50MB,一个游戏多语言全量打进去,包体直接就失控了。更合理的方式是每个语言只保留实际用到的字符,也就是动态字体按需生成字形,再配合AssetBundle按语言动态加载、用完即走。
1.2 为什么选TMP动态字体加AssetBundle这套组合
Unity的UI文字组件有两个世代:传统Text和TextMeshPro(TMP)。传统Text用系统的Font渲染,跨平台字形不一致。TMP则基于SDF(Signed Distance Field)技术,字体渲染更清晰,放大缩小不变形,而且支持动态字体——运行时按需把用到的字符生成到Atlas图集里,不必提前把所有字形打进去。多语言场景下,TMP的动态字体几乎是不二之选。
资源管理侧,常见选项有Resources、Addressables和AssetBundle。Resources目录的问题是包体无法热更、不支持按需加载,资源一旦打进去就常驻内存。Addressables本身是对AssetBundle的封装,学习曲线略高。AssetBundle最灵活,也最容易出错。我的选择是直接用AssetBundle,理由有三个:
- 字体包独立,可以放入整包或热更服务器,切换语言时按需下载。
- 一个语言一个Bundle,卸载方便,内存容易控制。
- 不引入额外框架,纯Unity自带API,团队维护成本低。
这套组合有个关键点:TMP的字体资源(TMP_FontAsset)依赖源字体文件(TTF/OTF),打包AssetBundle时如果依赖关系没理清,就会出现“字体Bundle加载成功但字体是空的”这种诡异问题。后面第3节单独讲。
2. TMP动态字体配置与字符集管理
2.1 创建中日文动态字体Asset
在Unity编辑器里操作大体是这样:
- 在Assets下建好
Fonts/CN和Fonts/JP两个目录,分别放入中文字体文件(例如思源黑体简体)和日文字体文件(例如思源黑体日文版)。 - 选中字体文件,通过菜单
Window/TextMeshPro/Font Asset Creator打开创建窗口。 - 点击
Generate Font Atlas,生成TMP_FontAsset,存到对应语言目录下。
这里有个细节:TMP官方建议用Source Font File直接生成,不要再手动复制成_OSFont之类。生成时务必把Render Mode设置为SDFAA或SDFAA_HINTED,前者抗锯齿更好,后者在小于36号字时更清晰。我用的是SDFAA_HINTED,在移动端小字号UI上观感明显更锐利。
Atlas设置是重点。默认Atlas Size是1024,对于中文本地化完全不够。常用汉字三千到五千个,1024的图集装不下,会触发TMP的“溢出”逻辑——新增字形时自动重建图集,运行时卡顿。建议至少设为2048,并勾选Multi Atlas Texture选项。这样单张图集满了会自动追加新图集,而不是频繁重建。
Sampling Point Size决定了字形采样的像素密度,我一般用60到90之间。值越大,字形在尺寸放大时越锐利,但Atlas占用也更大。如果游戏UI里存在大量大字号标题,采样点选太低会出现边缘糊的情况。
创建完两个字体Asset后,给它们起好清晰的名字,比如CN_Dynamic和JP_Dynamic。因为后面要在运行时通过代码查找和替换,命名规范很重要。
2.2 fallback机制解析
TMP有一个非常重要的机制:字体回退(Font Fallback)。当一个TMP_Text组件的当前字体找不到某个字符时,会自动去fallback列表里的其他字体查找。这个机制在日文场景下是救命稻草。
比如你的日文字体没有收录某些生僻汉字,可以给它设置一个fallback到中文字体。反过来,中文字体遇到假名时,fallback到日文字体。更稳妥的做法是:主字体还是日文专用字体,中文和日文的常用汉字都在日文字体里(日文字体通常包含JIS第一水准和第二水准汉字),但作为兜底,加上中文字体作为fallback,防止漏字。
配置方式很简单:在TMP_FontAsset的Inspector面板底部点击Add Fallback Font Asset按钮。运行时通过代码设置也支持:
japaneseFont.fallbackFontAssetTable.Add(chineseFont);注意fallback层级不能太深。TMP默认允许最多五层fallback链,每层查找都有开销。实测下来,日文字体加一层fallback就够了,别设计成A回退B、B再回退C的多级链条,真机上查找速度会明显变慢。
还要注意:fallback机制只在运行时查找字形时生效,如果你用静态字体方式预先打包,fallback不会自动把另一个字体的字形合进来。所以多语言场景必须配合动态字体使用。
2.3 字符集收集与Atlas参数选择
动态字体的核心优势是“用到什么字符才生成什么字形”,但这也带来一个问题:运行时第一次遇到新字符时,需要现场生成字形到Atlas,会产生瞬时卡顿。把这种卡顿完全消灭不现实,但可以大幅降低。
做法是在打包阶段就把所有可能用到的字符收集起来,预先塞进字体Asset的characterTable里。具体步骤:
- 从本地化配置表导出所有语言的文本内容。
- 写一个编辑器脚本,扫描这些字符串,去重后得到字符集。
- 用TMP的API将字符集写入对应动态字体Asset的字符表。
// 编辑器下执行:把收集到的字符预注册到动态字体 public static void PreRegisterCharacters(TMP_FontAsset fontAsset, HashSet<uint> chars) { foreach (uint unicode in chars) { fontAsset.TryAddCharacters(new uint[] { unicode }); } }运行后,这些字形的SDF数据会直接出现在Atlas中,玩家在游戏里看到这些文本时,不需要再额外生成字形,卡顿就消失了。
关于Atlas的Size,我建议不同语言区别对待:
| 语言 | Atlas Size | 采样点 | 预期占用 |
|---|---|---|---|
| 中文 | 2048 x 2048 | 72 | 约15-25MB GPU内存 |
| 日文 | 2048 x 2048 | 72 | 约10-18MB GPU内存 |
| 韩文 | 1024 x 1024 | 64 | 约8-15MB GPU内存 |
| 英文/数字 | 512 x 512 | 48 | 约2-4MB GPU内存 |
这里的占用是GPU上的Atlas纹理内存,不含CPU侧的SDF计算缓存。如果你的UI文本量非常大,比如聊天、邮件、图鉴系统,Atlas Size建议直接上4096,前提是目标设备支持。老机型不支持4096,需要做好降级策略,把字号稍微调小一点,或者改用1920。
3. AssetBundle打包、加载与全局替换
3.1 资源目录与打包策略
所有语言相关资源按语言分包,目录结构如下:
Assets/ Localization/ CN/ Fonts/ CN_Dynamic.asset UI/ LocalizedTexts.prefab JP/ Fonts/ JP_Dynamic.asset UI/ LocalizedTexts.prefab Config/ LanguageMap.asset打包时,每个语言目录单独打一个Bundle,BundleName直接用语言代码,比如lang_cn、lang_jp。这样切换语言时,只需要加载对应的一个Bundle,不需要加载其他语言的资源。
打包脚本的核心代码如下:
[MenuItem("Build/Build Language Bundles")] public static void BuildLanguageBundles() { string outputPath = "Assets/StreamingAssets/Bundles"; BuildTarget target = EditorUserBuildSettings.activeBuildTarget; AssetBundleBuild[] builds = { new AssetBundleBuild { assetBundleName = "lang_cn", assetNames = new[] { "Assets/Localization/CN/Fonts/CN_Dynamic.asset" } }, new AssetBundleBuild { assetBundleName = "lang_jp", assetNames = new[] { "Assets/Localization/JP/Fonts/JP_Dynamic.asset" } } }; BuildPipeline.BuildAssetBundles(outputPath, builds, BuildAssetBundleOptions.ChunkBasedCompression, target); }注意ChunkBasedCompression(LZ4)压缩方式,加载速度和压缩率平衡得最好。不要用LZMA,虽然体积最小,但解压时会把整个Bundle加载到内存,字体资源动辄几十MB,一解压就内存飙升。
3.2 运行时加载与替换流程
运行时加载的逻辑分为两步:第一步加载语言Bundle,第二步遍历UI并替换字体。
第一步:加载Bundle和字体Asset
public class FontManager : MonoBehaviour { private static FontManager _instance; private Dictionary<string, AssetBundle> _loadedBundles = new Dictionary<string, AssetBundle>(); public TMP_FontAsset LoadFont(string langCode, string fontAssetName) { string bundleName = $"lang_{langCode}"; if (!_loadedBundles.TryGetValue(bundleName, out AssetBundle bundle)) { bundle = AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, bundleName)); _loadedBundles[bundleName] = bundle; } return bundle.LoadAsset<TMP_FontAsset>(fontAssetName); } }这里有个很容易踩的坑:AssetBundle.LoadFromFile在移动平台上要求Bundle文件路径能直接访问。StreamingAssets目录在Android上会被压缩成APK的一部分,路径需要特殊处理,直接用Application.streamingAssetsPath拼路径在Android上会失败。正确做法是把它复制到persistentDataPath,或者使用UnityWebRequestAssetBundle.GetAssetBundle异步加载。
第二步:全局遍历并替换字体
遍历场景里所有TMP_Text组件,统一替换字体。但要注意,不是所有TMP_Text都需要替换。如果你有UI框架、弹窗系统,这些Prefab可能实例化了多个副本,直接FindObjectsOfType会重复处理,性能上不划算。更合适的做法是让所有本地化文本组件继承一个自定义组件,由该组件持有字体引用,然后只替换自定义组件。
public class LocalizedText : MonoBehaviour { public TMP_Text tmpText; } public class FontManager : MonoBehaviour { public void ReplaceAllFonts(TMP_FontAsset newFont) { var allTexts = FindObjectsOfType<LocalizedText>(true); foreach (var text in allTexts) { text.tmpText.font = newFont; text.tmpText.ForceMeshUpdate(); } } }ForceMeshUpdate这行不能省。TMP在字体替换后如果不清网格缓存,旧字形的网格信息还留在本地,不刷新就还是显示旧字体。我在第一次实现时漏了这步,切换语言后UI全新字体没生效,排查了很久。
3.3 缓存、引用计数与卸载时机
AssetBundle卸载是资源管理中最容易出错的一环。直接调用AssetBundle.Unload(true),会把该Bundle下所有资源直接销毁。如果此时场景里还有UI引用着这个字体,画面立刻变白块、甚至闪退。
正确做法是引入引用计数,或者用“延迟卸载”策略。
public class FontManager : MonoBehaviour { private Dictionary<string, int> _bundleRefCount = new Dictionary<string, int>(); public void RetainBundle(string bundleName) { if (_bundleRefCount.ContainsKey(bundleName)) _bundleRefCount[bundleName]++; else _bundleRefCount[bundleName] = 1; } public void ReleaseBundle(string bundleName) { if (!_bundleRefCount.ContainsKey(bundleName)) return; _bundleRefCount[bundleName]--; if (_bundleRefCount[bundleName] <= 0) { if (_loadedBundles.TryGetValue(bundleName, out var bundle)) { bundle.Unload(true); _loadedBundles.Remove(bundleName); _bundleRefCount.Remove(bundleName); } } } }切换语言时,流程是:先Retain新语言Bundle并完成字体替换,再Release旧语言Bundle。这样保证新旧Bundle在同一帧内共存,等所有UI刷新完毕后才真正卸载旧资源。
还有一个实用技巧:AssetBundle卸载后,之前通过它加载的TMP_FontAsset引用会失效,但对象引用不会自动置空。下次切回该语言时,如果缓存了一个已失效的字体Asset引用,直接用会出错。所以缓存的TMP_FontAsset必须和Bundle生命周期绑定,Bundle卸载时同步清掉字典里的缓存项。
4. 常见问题排查与避坑记录
4.1 问题速查表
这一节直接上干货,是我实测中遇到过的几个典型问题,整理成表,方便直接对照排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 切换语言后所有中文变成方块 | 新字体字符表里没收录这些字符,且没有fallback字体兜底 | 确认字体Asset预注册了完整字符集;配置fallback字体 |
| 日文假名不显示 | 日文字体Asset没包含假名,或主字体是中文,fallback没配置 | 主字体换成日文字体;给中文字体添加日文字体fallback |
| 切换时帧率骤降、卡顿 | 动态字体运行时为大量新字符生成字形 | 打包前预处理字符集;Atlas开启Multi Atlas纹理 |
| 字体替换后界面仍显示旧字体 | 没调用ForceMeshUpdate,TMP网格缓存未刷新 | 替换字体后立即调用ForceMeshUpdate |
| Android上加载Bundle失败 | StreamingAssets路径在Android上压缩在APK内 | 用UnityWebRequest或先复制到persistentDataPath |
| 内存持续上涨、卸载后不回落 | 字体Atlas一直驻留GPU;Bundle卸载时机不对 | 检查Atlas Size和Multi Atlas策略;用引用计数控制卸载 |
| 大字号标题字体边缘模糊 | Sampling Point Size设置过小 | 调高到90以上;大字号单独使用静态字体 |
4.2 资源管理避坑指南
第一坑:字体资源重复加载。同一帧内多个UI组件获取字体,如果每次都LoadAsset,会产生大量临时对象,内存碎片化严重。解决思路是在FontManager内部维护一个(语言码+字体名)到TMP_FontAsset的字典,只做一次LoadAsset,后续走缓存。上文代码里就是这么做的。
第二坑:异步加载与资源替换的竞态。如果你的语言包在热更服务器上,加载必须用异步接口。异步加载完成时,玩家可能已经再次切换语言了。所以回调里必须加版本判断:
public void ChangeLanguageAsync(string langCode) { string currentRequest = langCode; StartCoroutine(LoadFontBundleAsync(langCode, () => { // 如果这段时间玩家又切了一次语言,丢弃这次回调 if (currentRequest != _currentLang) return; ApplyFont(); })); }第三坑:TMP字体Asset被Unity自动清理。用AssetBundle.Unload(false)时,资源还在内存,但AssetBundle的依赖信息没了。此时如果对缓存的TMP_FontAsset调用TryAddCharacter,会报“which was not loaded from an AssetBundle”之类的错。所以凡是缓存字体引用的场景,一律用Unload(true)并把缓存清掉,不要偷懒用false。
第四坑:多页签、多预制体共享字体。团队协作里容易出现两个人各自创建了一份字体Asset,一份改动另一份没同步。建议把字体文件放在一个公共包或SVN/Git子模块里,并写一条编辑器校验规则:所有语言的Fonts目录下只允许存在一份同名Asset,Asset的GUID保持一致。这条规则在我们项目里救回来很多次。
第五坑:语言切换过程中UI预制体的处理。有些团队图省事,切语言时直接销毁当前场景重新加载。这样虽然不用做动态字体替换,但加载时间很长,体验很差。动态替换方案更优,只是要注意同时处理即时更新的文本内容。建议把文本内容的更新逻辑和字体替换分开:字体替换走FontManager,文案刷新走本地化表。二者各自独立,先刷新文案,再替换字体。
4.3 独门小技巧
最后分享一个实测很管用的小技巧:字体切换后,用协程延迟一帧再做一次ForceMeshUpdate。因为某些UI组件(尤其是带Outline、Shadow效果的TextMeshProUGUI)在同一帧内被多个系统更新,延迟一帧能确保所有网格都刷新干净,跳出“部分UI旧、部分UI新”的尴尬状态。
另外,如果你的UI里大量使用TMP的InlineSprite(表情、图标),注意InlineSprite的图集和字体Atlas是分开管理的,字体切换不会影响InlineSprite。但如果你的表情包也做成字体Asset,那就需要把它一并加入Fallback列表里,否则表情会变方块。
还有一点:做日文切换时,记得处理标点符号的差异。中文的逗号顿号、日文的句号逗号不同,而且日文文本基本都是纵向间距更紧凑。这套方案只处理字体,标点符号宽容度和排版间距需要另外配合TextMeshPro的Character Spacing和Line Spacing调整。我一般会在切换完字体后,根据语言代码覆盖TMP的样式参数,而不是写死在预制体里。
结尾
按我这几年的实操经验,多语言字体方案最忌讳“临时补丁”。很多项目都是中文版上线后才接日文,于是各种硬编码、复制粘贴式处理,结果越补越烂。建议从项目第一天就建立一个FontManager角色,语言、字体、Bundle生命周期都归它管,哪怕初期只有一种语言,也把这个架构先留出来。后面加语言,只是多打一个Bundle、多配一张字体表的事,不会伤筋动骨。
如果你正在做的是小而美的休闲游戏,这套方案或许略重,但考虑到后续可能上Steam、Switch多个平台,AssetBundle这套机制也完全可以平滑过渡。希望这篇避坑指南能帮你绕开我踩过的这些坑,真到切语言那天,能少掉几根头发。