技术博客

UID 系统:uid:// 路径如何映射到文件

第 99 / 149 篇 · 2025-04-29 · godot · 资源管线源码分析

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"时才出问题。

uid:// 引用解析流程与 UID 存储位置 引用方(.tscn) ext_resource uid=... get_id_path() ResourceUID 单例 ID→路径 哈希表 命中 res:// 实际路径 移动过文件也是新路径 未命中:报 Can't resolve uid,不降级 表的三个来源 编辑器:扫描时读每个资源文件头的 uid 行,灌入哈希表 64 位 ID 的文本编码(5 bit/字符 × 13) b 3 r 4 s x e s y 3 x w o 每组 5 bit,查 32 字符字母表 最高 1 位恒 0(ID 保证为正),62 位有效 → 生日界约 29 亿个才 50% 碰撞