UE5.8 版本控制与团队协作:从一次关卡冲突说起

📑 目录

上个月周五晚上十点,主程老周在群里发了张截图:他本地那张 L_AbandonedCity.umap 和服务器版本冲突,Resolve 窗口里只有两个按钮——Accept Source 和 Accept Target。他改了三天的光照烘焙,美术小林同一时间在同一个关卡里摆了新的广告牌。没有合并,只能二选一。老周把键盘一推,说了一句话:我们得聊聊版本控制了。

这正是 UE5.8 团队每天都在面对的命题。引擎越来越强,资产越来越重,协作模式却还停留在 UE4 甚至 UE3 的惯性里。下面我把这几年在几个项目里踩过的坑整理出来,希望能让你在周五晚上少砸一次键盘。

二进制资产的真相:是冰块,不是代码

Git 是给程序员写的。它默认你提交的是文本,diff 是一行一行看,merge 是三路合并。可 UE 里的 .uasset.umap.fbx.wav 全是二进制。你用文本 diff 打开一个 Blueprint,看到的是乱码和十六进制。

二进制资产没法像 C++ 那样自动合并。两个人同时改同一个材质球,结局就是两份文件,必须选一个。这个选择的成本,老周那晚已经付过了。

工程上有个简单的估算:假设一个关卡每天有 nn 个人会动,每个人平均每天提交 kk 次,关卡被同时改到的概率大约可以写成

Pconflict1eλnkt/TP_{conflict} \approx 1 - e^{-\lambda n k t / T}

其中 λ\lambda 是活跃系数,tt 是单次编辑时长,TT 是一天的工作窗口。人数一多,指数项很快就会让冲突几乎必然发生。这不是危言耸听,是我们在 8 人关卡组里统计过两周的数据。

所以二进制资产的管理核心就两件事:锁,或者拆。

Git LFS:让 Git 也能扛住大文件

很多独立团队和小型工作室从 Git 起家。Git 本身对大文件不友好,但 Git LFS 把它变成了可选项。原理很简单:仓库里只存一个文本指针,真正的文件放在 LFS 服务器上。

指针大概长这样:

version https://git-lfs.github.com/spec/v1
oid sha256:abc123...
size 123456789

实际使用时,我会在项目根目录放一段 .gitattributes

*.uasset filter=lfs diff=lfs merge=lfs -text
*.umap filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text

这样 Git 知道这些文件归 LFS 管。第一次 clone 时,只有当前 checkout 的文件会被下载,而不是把整个仓库历史全拖下来。

Git LFS 也支持文件锁:

git lfs lock Content/Levels/L_AbandonedCity.umap
git lfs locks
git lfs unlock Content/Levels/L_AbandonedCity.umap

锁的本质是告诉别人:这个文件我在改,你别动。但它的体验比 Perforce 差一截。锁状态不会自动出现在编辑器内容浏览器里,美术经常忘记锁,提交时才发现冲突。而且 Git LFS 锁在跨分支时行为也比较弱,branch 一多,锁会乱。

我们项目当时的妥协是:.uasset 走普通 Git(文件不大,数量多),.fbx.wav.umap 走 LFS,同时强制要求美术在改大文件前手动锁。这个规矩写在 CI 脚本里,未锁的 .umap 提交会被打回。

Git 团队的工作流

flowchart TD
    A[美术打开一个 .umap] --> B{文件是否被锁}
    B -->|是| C[看到锁拥有者,去喝咖啡]
    B -->|否| D[git lfs lock 文件]
    D --> E[编辑并保存]
    E --> F[提交并 push]
    F --> G[git lfs unlock]
    G --> H[其他人可继续编辑]

这个流程看起来不复杂,实际执行起来最大的敌人是忘了锁。所以后来我们给每个美术工位贴了张便利贴:改 .umap 前,先 git lfs lock

Perforce:锁就是默认

Perforce 在游戏行业能屹立这么多年,不是因为它时髦,而是因为它对二进制资产的假设从根上就是独占编辑。你 p4 edit 一个文件,服务器就给你锁上;别人再想 edit,会收到一个清晰的提示。

Epic 官方的 UE5 Perforce 集成做得相当好。内容浏览器右下角会显示文件状态:未签出、已签出、被谁锁了。保存时如果文件已被别人锁定,编辑器会直接弹窗阻止。

要让它自动锁,需要在 Perforce typemap 里把 .uasset.umap 设为独占锁:

p4 typemap

在弹出的编辑器里加:

binary+l //depot/.../*.uasset
binary+l //depot/.../*.umap
binary+l //depot/.../*.fbx
binary+l //depot/.../*.wav
binary  //depot/.../*.png
binary  //depot/.../*.jpg

binary+l+l 就是 lock,表示文件被签出时自动独占。这是 Perforce 比 Git LFS 锁更省心的地方:锁是工作流的一部分,而不是一个额外命令。

下面是一段常见的 Perforce 日常:

sequenceDiagram
    autonumber
    participant A as 美术小林
    participant P as Perforce Server
    participant B as 程序老周
    A->>P: p4 edit Content/Materials/M_Road.uasset
    P-->>A: 文件已锁定
    B->>P: p4 edit Content/Materials/M_Road.uasset
    P-->>B: 文件正被小林锁定
    A->>P: p4 submit
    P-->>A: 提交成功,锁释放
    B->>P: p4 edit Content/Materials/M_Road.uasset
    P-->>B: 现在可以编辑

这段对话每天在我们项目里发生几百次。它让冲突在保存之前就被挡住,而不是在周五晚上合并时才暴露。

.gitignore 与 .p4ignore:别让垃圾进仓库

无论用 Git 还是 Perforce,忽略规则都是第一道防线。最忌讳的是把 Saved/Intermediate/DerivedDataCache/ 提交进去。这些东西又大又没用,还会让新人的第一次 sync 变成灾难。

一个我们项目实际在用的 .gitignore 大概长这样:

# UE 工程临时目录
*/Saved/
*/Intermediate/
*/DerivedDataCache/
*/Binaries/
*/Build/

# 用户本地配置
*.suo
*.user
*.vs/
.idea/
.vscode/

# 平台产物
*.apk
*.ipa
*.exe
*.dll
*.pdb

# 日志与崩溃
*.log
crashinfo--*

# 素材源文件走 LFS 或 DCC 管理
*.psd
*.max
*.ma
*.blend1

Perforce 的 .p4ignore 语法和 Git 略有不同,核心思路一致:

# P4IGNORE for UE5
*/Saved/
*/Intermediate/
*/DerivedDataCache/
*.suo
*.user
*.tmp
*.pdb
*.log
.DS_Store

有个细节很多人会踩:.p4ignore 只对还没 add 的文件生效。如果已经提交过 Saved/,再写规则是没用的,需要先 p4 delete 掉。我们项目初期就因为这个原因多同步了 30GB 的缓存文件,后来花了两个晚上清理。

OFPA:把关卡拆成乐高

UE5 的 World Partition 带了一个叫 One File Per Actor(OFPA)的机制。开启后,关卡里的每个 Actor 会存成独立的 .uasset.uaactor 文件,放在 Content/__ExternalActors__/MapName/ 下面。原本一个 800MB 的 .umap,被拆成几万个几十 KB 到几 MB 的小文件。

这对协作是质变。小林改路灯,老周改光照,只要他俩动的不是同一个 Actor,就不需要锁,也不会冲突。只有在真正改到同一个 Actor 时,才会回到锁或二选一的老问题。

我用一个架构图说明:

architecture-beta
    group level[关卡 L_AbandonedCity]
    service umap(database)[L_AbandonedCity.umap]
    service actors(database)[__ExternalActors__]
    service actor1[路灯 Actor]
    service actor2[广告牌 Actor]
    service actor3[触发器 Actor]
    umap:R -- L:actors
    actors:T -- B:actor1
    actors:T -- B:actor2
    actors:T -- B:actor3

umap 文件本身还在,但它只保留关卡元数据:World Partition 网格、关卡设置、Data Layer 配置。真正的 Actor 数据都散落在 __ExternalActors__ 里。

OFPA 和冲突的关系

OFPA 把冲突的粒度从整个关卡降到了单个 Actor。我用一张关系图表示:

graph LR
    A[传统 .umap] -->|任何改动| B[整关冲突]
    C[OFPA 模式] -->|改 Actor 1| D[仅 Actor 1 冲突]
    C -->|改 Actor 2| E[无冲突]
    C -->|改 Actor 3| F[无冲突]

注意 OFPA 不是魔法。如果两个人同时改同一个路灯,依然要锁。只是日常工作中,这种概率比两个人同时改同一个 800MB 关卡低得多。

我们项目迁移到 OFPA 后,关卡相关的冲突从每周 5-8 次降到每月 1-2 次。这个数字够直观了吧?

冲突真的发生了怎么办

即使有了 OFPA 和锁,冲突仍会出现。常见场景:

  • 有人强行 unlock 了别人锁住的文件,然后保存。
  • 两个人在不同分支上改了同一个 Blueprint,最后合并。
  • Git 团队忘了锁 .umap,同时提交。

二进制冲突没有自动合并。你的选择通常是:

  1. 保留服务器版本,自己的改动手动重做。
  2. 强制用自己的版本覆盖服务器。
  3. 如果改的是 Blueprint,用 UE 的 Blueprint Diff 工具对比,手动把差异抄过去。

Blueprint Diff 能显示两个版本里哪些节点被增删改,哪些变量默认值变了。它不能一键 merge,但能让你看清对方到底改了什么。对于材质、关卡、动画这些没有 diff 工具的类型,只能靠锁和沟通。

DDC:别让缓存毁了仓库

Derived Data Cache(DDC)是 UE 编译和导入时生成的缓存:贴图烘焙、材质 Shader、Niagara 模拟数据。这些文件动辄几十 GB,而且每个人本地生成的一模一样。提交进版本控制等于给仓库灌铅。

我们的 .gitignore.p4ignore 都会排除:

*/DerivedDataCache/
**/DerivedDataCache/Boot.ddc
**/DerivedDataCache/**/*.udd

但 DDC 还有一个进阶玩法:共享 DDC。在团队里搭一台 DDC 服务器,或者把 DDC 放到 NAS / 对象存储上,所有人共享。这样新人第一次打开项目不用等半天烘焙,CI 打包也能复用缓存。

UE5 的 BaseEngine.ini 里可以配置共享 DDC:

[DerivedDataBackendGraph]
Shared=(Type=FileSystem, ReadOnly=false, Clean=false, Flush=false, PurgeTransient=true, DeleteUnused=true, UnusedFileAge=10, FoldersToClean=-1, Path=\\\\nas\\ddc\\shared)

路径换成你们自己的 NAS 地址。共享 DDC 是我们项目里最划算的一笔优化:每人每天省下的等待时间,换算成工资,几天就回本。

蓝图 Diff 与二进制合并的边界

UE 编辑器里自带 Blueprint Diff。右键一个 Blueprint 的历史版本,选 Diff Against Depot 或 Diff Against Local,能看到节点级别的差异。这个工具对程序看逻辑变更非常有用。

但它的边界很清楚:

  • 只能对比两个版本的节点图、变量、默认值。
  • 不能合并两个版本的 Blueprint。你看到差异后,只能手动把改动搬到自己版本。
  • 对纯数据资产(Data Asset),UE 提供了 Data Asset Merge,可以在一定程度上自动合并。

所以 Blueprint Diff 是看清战场的工具,不是一键结束战斗的武器。真正解决冲突,还得靠锁和沟通。

Plastic SCM:被低估的第三选项

除了 Git 和 Perforce,Plastic SCM 也是 UE 团队可以考虑的方案。它支持二进制文件锁、分支可视化、与 Unity 和 UE 都有集成。对于中小型团队,Plastic 的许可证成本有时比 Perforce 更友好,界面也更现代。

它的工作流介于 Git 和 Perforce 之间:可以像 Git 一样分布式分支,也可以像 Perforce 一样对 .uasset 加锁。如果你的团队既想要 Git 的分支自由,又想要 Perforce 的锁,Plastic 是个折中选项。

我们项目没深度用过 Plastic,但我认识的一个 12 人团队用了两年,反馈是锁比 Git LFS 稳,分支比 Perforce 活。这只是个参考,具体还得看你们的预算和托管方式。

一些能直接抄的作业

说了这么多,最后列一份我们项目验证过的 checklist。你可以直接贴到团队 wiki 里:

  • 所有 .uasset.umap.fbx.wav 默认走独占锁或 LFS 锁。
  • 开启 OFPA,把关卡拆成 Actor 级文件。
  • .gitignore / .p4ignore 必须排除 Saved/Intermediate/DerivedDataCache/Binaries/
  • 搭建共享 DDC,别让每个人本地重复烘焙。
  • 禁止 force unlock,除非锁的主人已经休假且改动已确认无效。
  • 每个周五下午做一次锁状态检查,清理幽灵锁。
  • 合并分支前,先用编辑器里的 diff 看一遍 Blueprint 变更。
  • 大版本升级或重构关卡前,先打标签或快照。

一个 Blueprint 冲突的实例

去年我们有个战斗 Blueprint:BP_PlayerAttack。程序阿杰加了一个新的 Combo 输入,策划小王调整了一段 Montage 播放逻辑。两个人都在自己的分支上改了两周,合并时冲突。

UE 的 Blueprint Diff 打开后,左边是服务器版本,右边是本地版本。阿杰看到小王把 PlayMontage 节点换到了 Branch 后面,而他自己新增了一个 InputAction 事件。两个改动都合理,但没法自动合并。

最后我们的处理办法:阿杰接受服务器版本,然后手工把自己的 InputAction 事件和变量重新连进去。花了四十分钟。如果这是 .umap 冲突,四十分钟根本不够,可能得重做一整天。

从那以后,我们规定:凡是 Blueprint,合并前必须先 diff;凡是 .umap,必须锁。

分支策略:别把 UE 当普通软件项目

很多程序出身的人喜欢 Git Flow:feature 分支、develop 分支、release 分支切得飞起。这在纯代码项目里没问题,但在 UE 项目里会撞墙。

因为二进制资产没法合并,feature 分支一旦涉及 .uasset.umap,合并到主分支时几乎必然冲突。我们团队的实践经验是:

  • 主分支只放稳定资产。美术每天的小改动直接提交到主分支,并用锁保证互斥。
  • 程序写 C++ 或改 Blueprint 逻辑时,可以切 feature 分支,但分支周期不要超过三天。
  • 任何 feature 分支合回主分支前,必须先同步主分支最新资产,确认没有未锁的 .umap
  • 大型重构关卡前,全员停下手头关卡相关改动,统一快照。

这套规矩听着死板,但它把冲突从合并时爆发转移到了编辑前就预防。

CI 和自动检查:用脚本守规矩

口头规矩总会有人忘。我们后来在 CI 里加了两条检查:

  • 每次 PR 如果包含 .umap 修改,必须同时存在对应的 LFS 锁记录或 PR 描述里有锁释放说明。
  • 提交内容不能包含 Saved/Intermediate/DerivedDataCache/ 里的文件。

Git 团队可以用一段简单的 pre-commit hook 做本地拦截:

#!/bin/sh
files=$(git diff --cached --name-only | grep -E '\.(umap|uasset)$')
for f in $files; do
  if ! git lfs locks | grep -q "$f"; then
    echo "未锁定文件: $f"
    exit 1
  fi
done

Perforce 团队可以在提交时要求 changelist 描述里写明锁状态。把检查交给机器,比靠人记靠谱得多。

迁移 OFPA 的注意事项

如果你是从 UE4 项目迁过来,开启 OFPA 不是勾个选项就完事。

  • 老关卡的 Actor 会重新序列化到 __ExternalActors__,第一次保存时会产生大量文件变更。不要在周五下午做这件事。
  • 文件夹数量会暴增。Windows 默认路径长度可能不够用,建议开启长路径支持。
  • 某些老插件或自定义序列化逻辑可能不兼容 External Actor,迁移前先在副本工程里跑一遍。
  • OFPA 文件很小,不建议全部走 Git LFS。我们的做法是把 .uaactor 和小的 .uasset 留在普通 Git,只把贴图、模型、音频走 LFS。

迁移后最直观的感受是:关卡设计师终于可以同时工作了。

再聊聊 DDC 的账

以我们项目为例,DDC 目录最高峰时本地有 47GB。8 个人就是 376GB 的重复数据。放到 NAS 共享后,新人第一次打开项目从 2 小时压缩到 15 分钟。这 15 分钟里大部分还是在读项目设置和加载插件。

这笔钱怎么算都值。一台普通的 NAS 加万兆交换机,成本比一个人两周工资还低。共享 DDC 是我们项目里最划算的一笔优化。

给不同规模团队的选型建议

我常被问:小团队该用 Git LFS 还是 Perforce?我的回答一贯很实际:看人头和看预算。

  • 1-3 人独立团队:Git 免费、周边工具多,配合 Git LFS 完全够用。只要养成锁 .umap 的习惯,冲突不会太多。
  • 5-15 人中小团队:如果有预算,直接上 Perforce。省下的沟通成本和冲突处理时间,够付好几年的授权费。
  • 20 人以上或有多地办公:Perforce 几乎是标准答案。配合 UnrealGameSync 和共享 DDC,能让整个团队保持在同一节奏。

Plastic SCM 适合那些卡在 Git 和 Perforce 之间的团队。它的锁比 Git LFS 稳,分支又比 Perforce 灵活,只是国内资料和托管方案少一些。

选型没有绝对的对错,只有合不合适。最怕的是项目做到一半发现工具撑不住了,再迁移一次,那才是真的伤筋动骨。

还有一点常被忽略:托管位置。Git LFS 的存储和带宽费用在资产膨胀后会变得很可观。Perforce 的授权费虽然 upfront,但大文件管理和锁的体验是算在总成本里的。不要只看第一年的账单,要算三年内的总拥有成本。

老周那晚的冲突,最后是靠 OFPA 迁移和强制锁规则解决的。三个月后,我们再也没在周五晚上因为 .umap 冲突吵过架。

版本控制工具不会替你沟通。再完美的锁也挡不住两个人商量好一起改同一个文件。真正让团队顺畅的,是每天早上的十分钟站会里,谁动过哪个关卡、哪个 Blueprint 一句话带过。工具负责兜底,沟通负责 Prevention。

版本控制这件事,技术选型只占三成,规矩和习惯占七成。选一个你们团队能坚持下去的方案,把锁、忽略规则和 OFPA 这三板斧用好,周五晚上就能准时下班。

最后多说一句:别把工具当成信仰。我见过用 Git LFS 跑得很顺的 AAA 项目,也见过用 Perforce 还天天冲突的团队。真正起作用的是人对规则的尊重。

规则执行得再死板,也比周五晚上删档重来强。选一个能睡得着的方案,才是正经事。写到这里,我又想起老周那晚的截图。希望你的群里再也不会出现那样的画面。