技术博客

ResourceUID 冲突与工程迁移:合并工程时的 uid 撞车处理

第 110 / 149 篇 · 2026-01-28 · godot · 资源管线UID工程管理

uid:// 系统(六-4 那篇讲过映射原理)的设计目标是:文件随便挪,引用不断。目标达成的代价是 id 必须全局唯一,而 id 是生成的,不是分配的——生成器看不见别的工程。于是"把 A 工程的 assets 目录拷进 B 工程"这种极其自然的操作,就有概率撞上两个文件同一个 uid。撞车不报错、不警告,表现是引用静默指错文件,排查起来非常阴间。这篇把撞车概率算清楚,给出合并工程前后的检查清单。

uid 是怎么生成的,为什么 64 位够用

ResourceUID::create_id()(core/io/resource_uid.cpp)的生成逻辑大致是:取当前毫秒时间戳的低位和一段随机数拼成 64 位整数。没有中央分配器,每个引擎实例、每次生成都独立抽签——和 UUID 的哲学一样,用位数换去中心化。

撞车概率用生日问题算。64 位 id 空间约 $2^{64} \approx 1.8 \times 10^{19}$,工程中 $n$ 个文件时,至少一对撞车的概率近似

$$ p \approx 1 - e^{-n^2 / 2^{65}} \approx \frac{n^2}{2^{65}} $$

代入数字:一万个文件时 $n^2 = 10^8$,$p \approx \frac{10^8}{3.7 \times 10^{19}} \approx 2.7 \times 10^{-12}$。单机一个工程内,这个概率低到可以放心忽略——这也是 Godot 敢用 64 位而不用 UUID 字符串的理由:id 要进二进制缓存和场景文件的 ExtResource 头,定长 8 字节比 36 字符的字符串在比较、哈希、存储上都便宜一个量级,而 64 位对"一个工程"这个尺度已经过剩。

但注意分子是 $n^2$:碰撞概率随文件数平方增长,且这是同一个随机源抽样两次的场景。如果你一年生成一百万个 uid(多工程合计、CI 批量生成资源),$p \approx \frac{10^{12}}{3.7\times10^{19}} \approx 2.7\times10^{-8}$,仍然安全。真正危险的从来不是随机撞车,是下面这种结构性撞车。

结构性撞车:拷贝与合并工程

随机撞车概率可忽略,但两种操作会制造非随机撞车:

  • 整目录拷贝资产:uid 跟着文件内容走。你把 A 工程的 textures/ 拷进 B 工程,文件带过来的 uid 是 A 生成的,和 B 已有的 uid 在同一个全局命名空间里——撞不撞全看运气,而拷贝通常一次几百上千个文件,一次抽几百张签;
  • 用旧版本工程做模板:新项目从老工程改出来,uid 原样保留,等于永远和老工程共享命名空间,后患无穷。

4.4 之后每个资源旁边有独立的 .uid 文件(之前存在 .godot/uid_cache.bin 里),拷贝目录会把 .uid 一起拷过去——这正是撞车的载体。讽刺的是这个设计本来是为了让拷贝和版本控制更安全(uid 不再藏在易丢失的二进制缓存里),结果是撞车也变得"自带干粮"。

撞车长什么样,怎么查

uid 冲突没有任何报错机制。ResourceUID 的映射表以 id 为 key,重复 id 后写覆盖先写(或者压根没进表),最终表现只有一种:某个 uid:// 引用指到了错误的资源。场景里贴图变成了另一张贴图、脚本换成了另一个脚本,编辑器里看不出任何异常标记。

排查手段是把映射表倒出来看。编辑器控制台或 @tool 脚本里跑:

@tool
extends EditorScript

func _run() -> void:
    var uid := ResourceUID.get_singleton()
    var ids := uid.get_ids()
    var seen := {}
    for id in ids:
        var text_id := ResourceUID.id_to_text(id)
        if seen.has(id):
            print("重复 uid: ", text_id, " -> ", uid.get_id_path(id))
        seen[id] = true
    print("共 ", ids.size(), " 个 uid,检查完成")

get_ids() 返回当前已注册的全部 id。注意这只覆盖注册过的——如果撞车的两个文件还没都被加载/扫描进缓存,这里查不出来。更彻底的办法是扫 .uid 文件本身:同名 uid 的两个 .uid 文件即铁证,一行 shell 就能出来:

grep -rh '^uid=' res:// --include='*.uid' | sort | uniq -d

消冲突流程

确认撞车后,处理原则是改新不改旧,改引用方不改被引方——但 uid 系统里"引用方"存的恰恰是 uid 字符串,所以正确姿势是给后进来的文件换新 uid:

var uid := ResourceUID.get_singleton()
var old_id := ResourceUID.text_to_id("uid://冲突的那个")
var path := uid.get_id_path(old_id)
uid.remove_id(old_id)                 # 摘掉旧映射
var new_id := ResourceUID.create_id() # 重新抽签
uid.add_id(new_id, path)
ResourceSaver.set_uid(path, new_id)   # 写回 .uid 文件 / 资源头

ResourceSaver.set_uid() 会把新 id 落盘。之后还要处理场景文件里的存量引用:如果场景 ExtResource 写的是 uid="uid://旧id" path="res://...",改成新 id——好在 ExtResource 同时存了 path,即使 uid 对不上,加载器也能按路径兜底找到正确文件(这正是双写的设计意图),所以你甚至有时间从容修。

uid 撞车:同一个 id 在两个文件上 工程 B 原有文件 res://ui/panel.png uid://a1b2c3 从工程 A 拷入的文件 res://fx/spark.png uid://a1b2c3 uid 表 场景里的引用指错资源 修法:给拷入文件换新 uid(ResourceSaver.set_uid),ExtResource 的 path 字段兜底,引用不会断

预防清单

  • 合并工程后、提交前,跑一遍上面的 uniq 检查,把冲突掐在进版本库之前;
  • 从旧工程"另起炉灶"时,删掉 .uid 文件让引擎重新生成,或者全量换新——别带着旧 uid 开工;
  • .godot/uid_cache.bin(4.4 之前的老工程)和 .uid 文件都要进版本库,否则队友机器上 uid 表重建时可能出现"路径相同但 id 不同"的另一种错配;
  • CI 上批量生成资源的流水线,生成后跑一次重复检查,成本几乎为零。

随机撞车不用焦虑,$10^{-12}$ 的量级你遇不到;结构性撞车每合并一次工程就抽一次奖,把检查做成流程,比祈祷可靠。

本文基于 Godot 4.4 的行为(.uid 文件方案);4.3 及更早版本 uid 仅存于 .godot/uid_cache.bin,检查脚本思路相同,落盘方式不同。