UE5.8 Localization 本地化:文本收集、翻译与多语言打包实践

📑 目录

UE5.8 Localization 本地化:文本收集、翻译与多语言打包实践

PixelLeaf Studio 是个三人小队,做了一款叫《猫与烧瓶》的解谜游戏。主角是一只会在实验室里乱窜的黑猫,玩家要帮它把各种试剂倒进对应的容器。Demo 在中文社区反响不错,发行商建议直接上 Steam 全球发售,支持英语、日语、韩语、德语、法语和巴西葡萄牙语。项目经理小夏接到需求时,第一反应是:把 UI 文字全翻译一遍不就行了?

两周后她发现事情没那么简单。德语单词长度把按钮撑变形了,日语对话框里的敬语漏翻了几处,韩语的组合字符在旧字体里显示成方块,巴西葡萄牙语的数字格式把小数点写成了逗号。最尴尬的是,游戏里一张猫挠门的提示图,在部分文化里被理解为捣乱而不是求助。本地化不是翻译,它是一套让游戏在不同语言和文化里都能正常呼吸的工程技术。

本文以小夏团队的踩坑经历为线索,梳理 UE5.8 里做好本地化的完整流程。

本地化工作流

在 UE5.8 里,本地化的核心思路是把所有面向玩家的文字、图片、音频从源码和资源中解耦,放到可被翻译和替换的位置。编辑器提供一个 Localization Dashboard,但整个链路可以拆成几个阶段:标记可翻译文本、收集到清单、导出给翻译、导入翻译、构建运行时资源、打包、游戏内切换。

flowchart TD
    subgraph ue ["Unreal Engine 5.8"]
        editor["Editor"]
        loc_dashboard["Localization Dashboard"]
        archive[".archive / .locres"]
        runtime["Game Runtime"]
    end
    subgraph external ["External Tools"]
        po_editor["PO Editor"]
        sheets["Spreadsheets"]
        translators["Translators"]
    end
    editor --> loc_dashboard
    loc_dashboard --> archive
    loc_dashboard --> po_editor
    po_editor --> sheets
    sheets --> translators
    archive --> runtime

这个架构图里最常被忽略的是中间格式。UE5.8 在编辑器里维护一份 .manifest 文件,记录所有可翻译文本的命名空间、键值和原始内容。翻译完成后,会生成 .archive 文件保存各语言译文,最后打包成 .locres 二进制文件供运行时加载。小夏一开始以为翻译直接写进蓝图或 C++,后来才知道所有译文都该走这条管线,否则更新时一定会漏掉。

工作流的起点是 DefaultGame.ini 里的配置。把需要支持的语言列出来,指定原生语言,设置文本收集的搜索路径。

[/Script/Engine.GameLocalizationSettings]
+NativeCulturePriorities=(Game=zh-Hans)
+GameTargetSet=True
+GameTargets=(Game)
+GameCultures=zh-Hans
+GameCultures=en
+GameCultures=ja
+GameCultures=ko
+GameCultures=de
+GameCultures=fr
+GameCultures=pt-BR

配置里 NativeCulturePriorities 指定默认语言。如果某条文本在目标语言里找不到译文,游戏会回退到默认语言显示,而不是直接空白。这条规则救了小夏好几次,尤其是测试期间翻译还没全部到位的时候。

具体执行流程可以用一张流程图概括。小夏团队每天收工前跑一遍收集,周五导出 PO 给翻译,周一导入新译文并开编辑器预览。

flowchart LR
    A["编写 UI 与剧情文本"] --> B["标记 FText"]
    B --> C["Localization Dashboard 收集"]
    C --> D["导出 .po / .xlsx"]
    D --> E["翻译与校对"]
    E --> F["导入译文"]
    F --> G["编辑器预览"]
    G --> H{通过?}
    H -->|"否"| B
    H -->|"是"| I["构建 .locres"]
    I --> J["打包并测试"]

流程图里的循环很关键。本地化不是一次性的任务,UI 文案会改,剧情会调,新功能会加。每次改动后都要重新收集,否则旧译文会和新原文错位。小夏给自己定了个规矩:任何出现在玩家面前的字符串,必须先确认它已经被 Dashboard 抓到,才能提交到版本库。

文本收集与翻译表

UE5.8 的文本收集依赖命名空间和键。命名空间通常按模块或资源划分,比如 UI_MainMenuDialog_Chapter1Item_Description。键则是在命名空间下唯一标识一条文本的字符串。编辑器在收集时,会自动给每个 FText 变量生成一个稳定的键,前提是你使用 NSLOCTEXT 宏或 LOCTEXT 宏,抑或在蓝图的 Text 属性里勾选本地化。

小夏团队里负责剧情的小马喜欢用 Excel 写台词,写完后直接粘贴到蓝图里。结果收集时发现大量文本的键是自动生成的哈希值,翻译同事完全看不懂上下文。他们后来改成在蓝图的 Details 面板里手动给每个对话节点起键名,比如 Dlg_Intro_Line01,翻译表立刻变得可读。

下面是一段典型的 C++ 用法。LOCTEXT 会根据当前文件名生成命名空间,NSLOCTEXT 则允许显式指定。

void UMyGameInstance::ShowWelcome()
{
    FText Welcome = LOCTEXT("WelcomeLine", "欢迎回来,猫博士。");
    FText ItemName = NSLOCTEXT("Item", "PotionRed", "红色药剂");
    UKismetSystemLibrary::PrintString(this, Welcome.ToString());
}

代码只有短短几行,却决定了后续翻译是否可控。LOCTEXT 的第二个参数是原文,"WelcomeLine" 是键。当原文被收集到 .manifest 后,翻译表会保留这个键,翻译人员看到原文和键,就能判断上下文。小夏建议:凡是会显示给玩家的文本,一律用 FText 包装,并且给每个键起有语义的名字。

导出给翻译的格式通常是 .po.xlsx.po 是 gettext 标准格式,翻译工具支持好;.xlsx 更适合不熟悉 PO 的翻译同事。UE5.8 的 Dashboard 可以直接导出 PO,再用 Python 脚本转成 Excel,翻译完成后再转回 PO。小夏写了一个简单的转换脚本,把 PO 的 msgctxtmsgidmsgstr 映射到 Excel 的三列。

键 (Key)源文本 (Source)英语 (en)日语 (ja)备注
WelcomeLine欢迎回来,猫博士。Welcome back, Dr. Cat.お帰りなさい、猫博士。主界面
PotionRed红色药剂Red Potion赤い薬背包物品
Btn_Continue继续Continue続ける通用按钮

表格里加一列备注是翻译同事强烈要求的习惯。日语的 続ける 在按钮上没问题,但如果文案出现在暂停菜单,可能要用更正式的 コンティニュー。上下文不同,译文可能完全不同。小夏把这些备注也一起导入 PO 的 extracted comments 字段,这样翻译工具里能看到。

FText、FName、FString 选择

Unreal Engine 里处理字符串有三种主要类型,本地化的正确性很大程度上取决于你用哪一种。

FString 是可变的 Unicode 字符串,灵活性最高,但几乎不应该直接拿来做玩家可见文本。因为它没有任何本地化元数据,翻译系统无法追踪,运行时也无法按语言切换。小夏团队早期把一些提示文字直接拼成 FString,结果这些文本在 Dashboard 里完全搜不到,翻译漏掉了一大块。

FName 是引擎内部的快速比较字符串,大小写不敏感,存储在全局表里。它适合作为标识符,比如资源路径、枚举名、配置键。小夏用 FName 做物品 ID,比如把红色药剂记成 Item_PotionRed,但显示名称一定转成 FText 再呈现。

FText 才是本地化的主角。它不仅保存字符串内容,还保存命名空间、键、原始语言、翻译状态等元信息。UE5.8 的 FText 支持格式化参数、复数形式、性别变化,这些功能在多语言环境里非常实用。比如英语里 You have {0} potion 在数量为 1 和 2 时要分别变成 potionpotions,而中文没有单复数变化,可以直接忽略。

void UMyHUD::UpdatePotionCount(int32 Count)
{
    FFormatNamedArguments Args;
    Args.Add(TEXT("Count"), FText::AsNumber(Count));
    FText Message = FText::Format(
        NSLOCTEXT("HUD", "PotionCount", "拥有 {Count} 瓶药剂"),
        Args
    );
    PotionText->SetText(Message);
}

FText::Format 接受命名参数,翻译人员可以自由调整语序。英语可以译成 You have {Count} potion(s),并通过复数规则处理单复数;日语可以译成 薬を{Count}本持っています。语序、量词、复数都交给目标语言自己决定,这就是 FText 比硬编码 FString 高明的地方。

选择规则可以总结成一句话:玩家看得见的用 FText,引擎内部标识用 FName,临时拼接或调试信息用 FString。小夏在代码评审时专门加了一条:凡是直接设置到 UTextBlockFMessageDialog 的字符串,必须来源 FText

本地化预览

翻译导入后,不可能每次都在真机上跑一遍。UE5.8 的编辑器预览模式可以快速切换当前语言,检查文本是否越界、字体是否缺失、布局是否错乱。小夏团队的做法是:在 Editor Preferences 里把 Preview Game Language 切到目标语言,然后在 PIE 模式里跑关键界面。

sequenceDiagram
    actor Dev as "开发者"
    participant Dash as "Localization Dashboard"
    participant Editor as "编辑器预览"
    actor Trans as "翻译人员"

    Dev ->> Dash: 收集文本并导出 PO
    Dash -->> Trans: 发送翻译文件
    Trans ->> Dash: 提交译文
    Dev ->> Dash: 导入并编译
    Dev ->> Editor: 设置 Preview Language
    Editor -->> Dev: 显示目标语言 UI
    Dev ->> Trans: 反馈截图与问题

序列图里的反馈循环是小夏后来加上的。很多布局问题只有开发者能在编辑器里看到,比如德语把按钮撑出屏幕。她把问题截图贴回给翻译,商量是否缩短译文,或者调整 UI 布局。有时候翻译坚持准确表达,那就只能让 UI 设计师把按钮做宽一点。

预览时还要注意伪本地化。UE5.8 支持生成伪语言,比如把 欢迎回来 变成 Ŵêļçõмэ бáçķ,同时在前后加占位符以模拟长文本。这种预览不需要真实翻译,就能提前暴露字体、布局、文本截断问题。小夏在正式启动多语言翻译前,先用伪本地化跑了一遍全部界面,修掉了七八个固定宽度文本框。

另一个容易忽略的细节是字体回退。中文可以用思源黑体,日语用 Noto Sans JP,韩语用 Noto Sans KR。但 UI 里偶尔会出现数字、符号、玩家输入的英文,这些字符可能不在当前字体里。UE5.8 的字体资产支持 Fallback Fonts,小夏把 Noto Sans CJK 作为多个语言字体的公共回退,确保特殊符号不会变成 tofu。

数字、日期、货币格式化

本地化的难点不只在于文字,还在于数字和日期的表达方式。中文和英语的小数点都是点号,但德语、法语、巴西葡萄牙语常用逗号。千分位分隔符在英语里是逗号,在德语里是点号。如果直接把 float 转成 FString 再显示,德国玩家看到的金额会完全错乱。

UE5.8 的 FText::AsNumberFText::AsPercentFText::AsCurrencyFText::AsDateFText::AsTime 都会根据当前 locale 自动选择格式。下面的代码展示了如何安全地显示一个价格和日期。

void UShopWidget::ShowPrice(float Price)
{
    FText PriceText = FText::AsCurrency(Price, TEXT("USD"));
    FText RateText = FText::AsPercent(0.085f);
    FDateTime Now = FDateTime::Now();
    FText DateText = FText::AsDate(Now, EDateTimeStyle::Short);

    PriceLabel->SetText(PriceText);
    RateLabel->SetText(RateText);
    DateLabel->SetText(DateText);
}

这段代码在不同语言下会自动适配。英语显示 $1.99,德语显示 1,99 $,日语显示 $1.99。日期在英语里是 7/15/26,在德语里是 15.07.26,在日语里是 2026/07/15。引擎已经内置 ICU 数据,开发者不需要手动维护格式规则。

如果团队想自己验证覆盖率,可以用一个简单的公式。假设项目里有 NN 条可翻译文本,当前语言已完成 MM 条,那么覆盖率 CC 可以写成:

C=MN×100%C = \frac{M}{N} \times 100\%

小夏每周五会统计一次 CC。当某个语言低于 95%95\% 时,她会暂停该语言的打包,避免玩家看到半成品。公式虽然简单,但能防止侥幸心理。

百分比显示还有一个细节。FText::AsPercent(0.085f) 在不同地区可能显示为 8.5%8,5 %8.5%。如果游戏里有装备强化成功率、抽卡概率,这些差异会影响玩家理解。小夏团队把所有概率展示都改成 FText::AsPercent,并统一保留一位小数,避免文化差异造成误会。

图片与音频本地化

文字翻译只是第一步。图片里的文字、语音、口型、甚至某些图标,都需要根据地区调整。UE5.8 对图片和音频本地化提供了两条路径:使用 UFONTUTexture 的 Localization 覆盖,或者在运行时按文化路径加载。

图片本地化的常用做法是把纹理放在 Content/Localization/{Culture}/Images/ 下,然后在蓝图中根据 FInternationalization::Get().GetCurrentCulture() 动态加载。小夏游戏里有一张任务提示图,上面写了中文说明。她做了六套图,分别对应六种语言,命名保持一致,只是放在不同文化目录下。加载时先拼路径,再调用 LoadObject<UTexture2D>

UTexture2D* UHintLibrary::LoadLocalizedTexture(const FString& BaseName)
{
    FString Culture = FInternationalization::Get().GetCurrentCulture()->GetName();
    FString Path = FString::Printf(
        TEXT("/Game/Localization/%s/Images/%s.%s"),
        *Culture, *BaseName, *BaseName
    );
    return LoadObject<UTexture2D>(nullptr, *Path);
}

代码里用 %s 拼出带语言前缀的资源路径。如果当前文化找不到对应文件,可以再回退到默认语言。小夏的回退逻辑是:先找 pt-BR,找不到就找 pt,再找不到就找 en,最后才是 zh-Hans。这种阶梯式回退和文本本地化的回退规则一致,玩家不会看到空白图。

音频本地化更复杂。配音需要重新录制,唇形同步也要调整。UE5.8 的 Dialogue Wave 和 Sound Wave 都支持按文化指定不同资产。小夏的团队没有预算做全语音,于是他们只做英语配音,其他地区用字幕。但一些关键音效,比如猫的叫声、实验室的背景音, universal 即可,不需要替换。

图标和文化敏感度也要检查。小夏原来用了一只竖起大拇指的图标表示赞同,后来有人提醒她在部分中东和南美地区这个手势含义负面。她把它改成了更中性的对勾图标。这类问题没有自动化工具能完全解决,只能靠文化审查清单逐条核对。

打包与运行时切换

翻译和资源都准备好后,下一步是打包。UE5.8 在打包时会根据 Project Settings -> Packaging -> Localizations to Package 选择要打进包里的语言。默认可能只打原生语言,需要手动勾选目标语言。小夏第一次打包测试版时,只有中文能显示,其他语言全是空白,后来发现就是这里没勾选。

打包后的运行时结构大致如下。每种语言有一份 .locres 二进制文件,游戏启动时根据系统语言或玩家设置加载对应文件。

graph TD
    A["游戏启动"] --> B["读取系统语言"]
    B --> C{玩家是否手动设置过}
    C -->|"是"| D["使用玩家设置"]
    C -->|"否"| E["使用系统语言"]
    D --> F["加载对应 .locres"]
    E --> F
    F --> G["进入主菜单"]
    G --> H["玩家切换语言"]
    H --> I["保存设置"]
    I --> J["重启或重新加载 .locres"]

运行时切换语言有两种策略。一种是切换后立即重新加载 FText 资源,刷新所有 UI;另一种是提示玩家重启游戏。UE5.8 的 FInternationalization::Get().SetCurrentCulture 可以切换当前文化,但已经创建出来的 FTextSnapshot 或缓存的字符串不会自动刷新。小夏选择了一种折中方案:切换语言后回到主菜单并重新创建所有 UI 控件,这样新语言能立刻生效,同时避免在关卡中途刷新造成状态错乱。

打包体积也需要权衡。六种语言的文本加图片、音频,会让包体明显变大。小夏团队最终选择把图片和音频按语言拆分成 DLC 或补丁包,基础包只保留最常用的英语和中文,玩家可以在设置里按需下载其他语言资源。UE5.8 的 Pak 文件和 DLC 机制支持这种拆分,只需要在构建时指定每个 Pak 对应的文化目录。

运行时加载的性能可以用 O(1)O(1) 来概括:.locres 内部用哈希表组织键到译文的映射,查找文本的开销与语言数量和总文本量关系不大,单次查找接近常数。假设游戏有 nn 种语言、kk 条文本,内存中同时只保留当前语言的 kk 条记录,所以运行时内存占用是 O(k)O(k),而不是 O(n×k)O(n \times k)

常见本地化问题

小夏团队在六个月的本地化过程中踩了不少坑,这里挑几个典型的分享。

第一个是硬编码文本。有些报错信息、调试日志、甚至是临时写死的任务描述,直接用了 FString 或裸字符串。这些文本在 Localization Dashboard 里收集不到,玩家看到的还是中文。解决办法是定期运行 Text Export 后做 diff,如果某段中文出现在目标语言包里,说明它没有被 FText 标记。

第二个是过度翻译。有些字符串不应该被翻译,比如控制台命令、存档文件名、内部枚举名、协议字段名。如果把这些也丢给翻译,轻则是乱码,重则破坏功能。小夏在导出 PO 前会用 MustNotBeLocalized 标记或在命名空间上过滤,把内部字符串排除。

第三个是复数和性别。英语有单复数,法语、德语、俄语还有性别和格的变化。FText::Format 配合 ICU Message Format 可以处理大部分情况,但前提是翻译人员了解写法。小夏给翻译同事写了一份 brief,说明哪些键使用了复数占位符,要求译文保留 {0, plural, one {...} other {...}} 的结构。

第四个是字体和字符集。日语假名、韩语谚文、德语变音符号都需要字体支持。小夏用 Font Asset 的 Chars 面板检查每个字体覆盖的 Unicode 范围,确保没有遗漏。她还发现某些旧设备不支持彩色 emoji,于是把成就图标改成了位图,而不是依赖系统字体渲染。

第五个是测试覆盖率。翻译完成后不能只跑一遍主菜单就结束。剧情对话、错误提示、设置界面、成就系统、多人聊天,都可能包含隐藏文本。小夏团队做了一张检查表,每条功能至少用一种目标语言完整跑一遍,覆盖率公式再派上用场:

C_{\text{test}} = \frac{\text{已验证界面数}}{\text{总界面数}} \times 100\%

CtestC_{\text{test}} 达到 100%100\% 时才允许提交最终构建。这个公式和翻译覆盖率公式一起,成了小夏判断本地化是否就绪的两把尺子。

收尾

《猫与烧瓶》上线三个月后,全球销量里接近四成来自非中文地区。小夏复盘时说,本地化不是项目末期才补的补丁,而是贯穿整个开发流程的基建。UE5.8 提供的 Dashboard、FText、ICU 格式化、文化回退、Pak 拆分,已经把技术门槛降得很低,剩下的就是流程和耐心。

如果你也正在准备出海,不妨从第一句玩家看得见的文本开始:把它写成 FText,起一个语义化的键,放进 Dashboard,导出给翻译,预览,打包,测试。循环往复,直到所有语言里的猫,都能被正确理解。