3.x 时代资源之间的引用是纯路径:.tscn 里写 res://assets/hero.png。美术把文件挪个目录,引用全部断掉,编辑器只能逐个帮你改。Godot 4 的答案是 UID——每个资源分配一个全局唯一的 64 位整数,引用方记 UID,运行时再通过查表翻译成路径。这篇把这套机制拆开:UID 长什么样、存在哪、什么时候查表,以及我踩过的 UID 撞车坑。
UID 的编码格式
.tscn 里你会看到这样的行:
[ext_resource type="Texture2D" uid="uid://b3r4sxesy3xwo" path="res://assets/hero.png" id="1_x7ab2"]
uid:// 后面的字符串是 64 位整数的文本编码。翻 core/io/resource_uid.cpp 的 id_to_text(),编码规则是把 ID 从低位到高位每 5 位切成一组,每组查一张 32 字符的字母表(大小写字母加数字的一部分)得到一个字符,拼出 13 个字符:
$$13 \times 5 = 65\ \text{bit} \geq 64\ \text{bit}$$
最高那一位恒为 0——create_id() 强制保证 ID 是正数,位 63 空出来留作将来扩展。所以一个 UID 的实质就是一个正的 int64,文本形式只是给人看的。
为什么用 5 位一组而不是标准的 base64(6 位一组)?标准 base64 编码 64 位需要 $\lceil 64/6 \rceil = 11$ 个字符,但解码时最后一组有 4 位填充,位运算要处理尾巴的不对齐。5 位一组虽然多两个字符,却实现了完全对齐:任何一组字符都对应整数里的连续 5 位,编码和解码都是纯粹的移位加查表,代码十几行。可读性换简洁性,这个取舍很 Godot。
三处存储,一张表
UID 相关的状态分散在三个地方,职责各不相同:
资源文件自己。 文本资源把 UID 写在文件头。.tscn / .tres 是 [gd_scene load_steps=3 format=3 uid="uid://..."] 这一行,.gd 脚本是文件顶部的注释 # uid://...。二进制资源(.scn / .res)则在文件头专门的 chunk 里。这个设计让"扫描文件就能收集全部 UID"成立——不需要额外数据库。
.godot/uid_cache.bin。 ResourceUID 单例(core/io/resource_uid.h)维护两张哈希表:ID→路径、路径→ID。编辑器扫描工程时把读到的 UID 灌进表,退出时序列化到 uid_cache.bin。导出时这个文件打进 PCK,游戏运行时整张表是静态的,运行时创建的资源(ResourceSaver.save() 新存一个)也在内存里登记。
引用方。 谁引用资源,谁的 ext_resource 行就带上 uid="..."。这是断链修复的关键——路径变了没关系,只要文件还在工程里,查表就能找到新位置。
查表流程与边界
加载一个 uid:// 引用的完整路径:
uid://b3r4sxesy3xwo
│
▼
ResourceUID::get_singleton()->get_id_path(id)
│
├── 命中 → 返回 res://assets/hero.png(移动过也是新路径)
│
└── 未命中 → ERR + 返回空 → 加载失败
两条边界要清楚。第一,导出的游戏里 UID 表是冻结的:uid_cache.bin 在 PCK 里,运行时不会重新扫盘。PCK 之外的路径根本不在表里,所以"运行时从用户目录 load 一个 uid://"这件事不成立,UID 只服务于包内资源。第二,查表失败没有降级。3.x 遇到断链会报"文件不存在",4.x 遇到未知 UID 报 Can't resolve uid——它不知道文件去哪了,因为表里就没有这个 ID,引擎不会拿着旧路径猜。
碰撞概率顺带算一下。ID 有效位约 62 位(去掉符号位和保留位),随机生成。按生日悖论,出现 50% 碰撞概率需要的资源数量约为:
$$n \approx \sqrt{2 \times 2^{62}} = 2^{31.5} \approx 2.9 \times 10^{9}$$
接近 30 亿个资源才有一半概率撞一次,单个工程离这个数量级差了五个数量级,所以 create_id() 里甚至没有主动查重逻辑(编辑器侧另有确保唯一的逻辑,但成本可忽略)。这套数学是 UID 敢于全局随机分配的底气。
踩坑:复制文件不生成新 UID
我遇到过一次诡异的加载错误:加载 A 贴图报"UID 指向的文件和路径不一致",同时 B 贴图莫名变成 A 的内容。原因是用文件管理器(不是编辑器)复制了 hero.png 得到 hero2.png。复制出来的新文件带着旧文件的 UID 头,扫描时 uid_cache.bin 里同一个 ID 被登记两次,后扫描到的覆盖先到的,两张图在表里互相打架。
正确处理复制资源的方式有两种:在编辑器里复制(FileSystem 面板右键 Duplicate,编辑器会重新生成 UID),或者手动把新文件头部那行 uid= 删掉让编辑器下次扫描时补发一个。工程里如果已经出现撞车,uid_cache.bin 删掉让编辑器重建即可——它是纯缓存,.godot/ 目录本来就不该进版本库。
反过来也有一个"坑":如果你用文本编辑器手动改 .tscn 时照抄了一行 ext_resource 的 uid,两个场景引用同一份资源倒是合法的、也是常见做法——UID 本来就是为多对一引用设计的,撞车只在"两个不同文件声称自己是同一个 UID"时才出问题。