调用 RenderingServer 创建一张纹理,拿到的不是 Texture*,而是一个 RID——一个 64 位无符号整数。之后所有操作都是 RS::get_singleton()->texture_update(rid, ...) 这种"句柄 + 参数"的形式。整个 servers 层(渲染、物理、音频、导航)对外不暴露任何裸指针,这是 Godot 架构里和"server 模式"配套的核心设计。这篇拆 core/templates/rid.h 和 rid_owner.h,讲清句柄表的实现和它换来的三个能力。
RID 的本质:带版本号的索引
RID 不是随机数,而是槽位索引 + 版本号的组合(core/templates/rid.h):
class RID {
union {
uint64_t _id;
struct {
uint32_t index; // 槽位索引
uint32_t validator; // 版本号/校验码
} _data;
};
public:
_FORCE_INLINE_ uint32_t get_index() const { return _data.index; }
_FORCE_INLINE_ uint32_t get_validator() const { return _data.validator; }
bool is_valid() const { return _id != 0; }
bool is_null() const { return _id == 0; }
};
低 32 位是对象在句柄表里的槽位索引,高 32 位是该槽位的版本号。每次槽位被释放再复用时,validator 递增——这样"拿着旧 RID 访问已释放对象"会被版本不匹配拦住,而不是读到复用后的新对象(A 释放、B 复用同一槽位,旧 RID 访问 B 是灾难)。这就是经典的 handle with generation counter 模式,游戏引擎里解决悬垂引用的标准做法(Entity Component System 的 entity ID 同理)。
RID_Owner(core/templates/rid_owner.h)管理这张表:
template <typename T>
class RID_Owner {
struct Slot {
T *ptr; // 实际对象指针
uint32_t validator; // 当前版本号
bool occupied;
};
Vector<Slot> slots; // 槽位数组,索引即 RID 的 index 部分
SpinLock spin_lock; // 多线程分配保护
// ...
};
分配 RID 时找一个空槽位(空闲链表),validator 递增后填入,返回 index | (validator << 32);查找时用 index 定位槽位,比对 validator,匹配则返回 T*。整个过程是 $O(1)$:一次数组索引加一次整数比较。
为什么不用裸指针:三个换来的能力
句柄模式比裸指针多一次查表(数组索引),换来的是三个裸指针给不了的东西。
线程安全。 server 层的典型场景:主线程调 RS::get_singleton()->mesh_create() 拿到 RID,渲染线程在下一帧用这个 RID 访问网格数据。如果是裸指针,主线程在渲染线程读取期间 delete 指针就是 use-after-free。用 RID,释放操作只是把槽位标记空闲并递增 validator——渲染线程拿着旧 RID 访问时版本不匹配,安全地返回 nullptr,而不是踩到野内存。引擎里大量"主线程改资源、渲染线程读"的路径依赖这个保证。
热重载。 编辑器里改一张贴图,场景里所有引用它的材质要实时刷新。实现方式是:旧纹理对象析构,新纹理对象分配到同一个槽位(编辑器导入器强制要求),RID 不变但 validator 递增。场景层持有的 RID 还有效(index 相同),访问时拿到的是新对象。裸指针做不到这一点——指针地址变了,所有持有者都要更新。句柄的间接层让"换内容不换身份"成为可能。
序列化与网络同步。 RID 是值类型,可以直接写进场景文件或通过网络发送。PackedScene 里节点引用渲染资源的方式就是存 RID,加载时重建映射。裸指针不能序列化,必须额外维护一张指针→ID 的映射表。
查表开销:一次 L1 缓存命中
RID 查找的成本常被高估。实际路径是:
RID → index (低 32 位) → slots[index] (数组访问) → 比对 validator → 返回 ptr
slots 是 Vector<Slot>,连续内存。index 是顺序分配的(空闲链表头大概率是最近释放的槽位),访问模式有良好的时间局部性。在典型负载下(几千个活跃对象),整个 slots 数组几十 KB,完全在 L2 缓存里,热路径上的槽位在 L1。一次 RID 解析的开销约等于一次指针解引用——相比裸指针的"零开销",多的是一次边界检查和版本比较,都在寄存器里完成。
真正的成本在错误使用上:把 RID 存起来每帧都查(而不是缓存 T*),或者在高频循环里反复 is_valid()。引擎内部的纪律是"进入热路径前解析一次,循环里用裸指针"——这和"进内核态一次拿 fd,之后用 fd 数组索引"是同一个优化思想。
RID_Owner 的槽位复用与内存紧缩
句柄表的一个隐性问题是槽位碎片:高频创建销毁会让 slots 数组膨胀(峰值 10 万对象,常态 1 千,数组还是 10 万槽位)。RID_Owner 的对策是惰性紧缩——slots 不主动缩小,但空闲槽位用侵入式链表串起来,分配时优先复用低索引槽位。这样数组容量按历史峰值分配,但活跃槽位集中在低地址,缓存友好性不受碎片影响。
紧缩的时机在 RID_Owner::cleanup()(server 关闭时)和 RID_Owner::compact()(编辑器里手动触发,如切换场景后)。compact 会把活跃对象搬移到连续低索引,释放高索引内存——这个操作要移动 T* 指针,所以只在"没有渲染帧在飞"的安全窗口执行。
RID 与 Variant 的交接
RID 在 Variant 里的表示(上一篇讲的 union)是内联 8 字节——_data._rid 直接存 64 位值,不走指针。这意味着脚本里 var rid = RenderingServer.mesh_create() 的传递是零分配的值拷贝。但脚本侧不能解引用 RID——GDScript 没有 rid.get_object() 这种 API,RID 在脚本里只是不透明令牌,所有操作必须回 server 层。这是故意的:句柄的解引用权集中在 server 手里,脚本无法绕过版本检查直接碰对象内存。
句柄生命周期
什么时候不该用 RID
RID 不是银弹,引擎里有明确的不用它的场合:
- Node 之间不用 RID:场景树内部直接用
Node*裸指针,因为树结构本身管理生命周期(parent 析构时递归析构 child),不需要额外的句柄层。RID 的开销在这里是浪费; - 高频数学计算不用 RID:Transform3D、Vector3 是值类型,没有"句柄"概念;
- GDExtension 边界:扩展和引擎之间传的是
GDExtensionObjectPtr(裸指针加类型标签),不是 RID。RID 是引擎内部的优化,不泄漏到 ABI 边界——这是"句柄解耦"和"ABI 稳定"的分工。
判断标准很简单:对象生命周期由谁管。SceneTree 管的对象(Node)用树结构,server 管的对象(纹理、网格、物理体)用 RID,脚本管的对象(Resource)用 Ref。三套机制对应三种所有权模型,混用才会出问题。
RID_Owner 的线程模型:SpinLock 只管分配
前面提到 RID_Owner 里有一把 SpinLock,它的保护范围有讲究——只锁分配和释放,不锁查找。分配/释放是低频操作(创建销毁一个纹理才一次),锁一下无所谓;查找是每帧几十万次的热路径,加锁会直接拖垮渲染线程。
查找无锁的安全性靠两个保证。其一是槽位数组在运行期不扩容——slots 的容量在 server 初始化时按 RID_MAX_ALLOCATION 预分配(或按需倍增,但倍增只发生在持有锁的分配路径里,且查找方通过原子读数组指针拿到新表)。其二是 validator 的读写在单字节对齐的 uint32_t 上是原子的(x86/ARM 的对齐 32 位读都是单指令)。这套"读写无锁、写者加锁"的模式和 Linux 内核的 RCU 思想同源:读路径零开销,写路径付全部同步成本。
实际使用时还有一层封装:RenderingServer 内部不是直接用 RID_Owner<T>,而是用 RID_Alloc<T>(core/templates/rid_owner.h 里的别名模板),它在 RID_Owner 基础上加了"分配时构造、释放时析构"的对象生命周期管理。所以 server 代码里看到的 mesh_owner.allocate_rid() 返回 RID 的同时就完成了 T 的就地构造——RID 和对象的生命周期严格绑定,不存在"有 RID 没对象"的中间态。
一个容易踩的坑:RID 的跨 server 无效性
RID 是按 owner 命名空间隔离的——RenderingServer 的 mesh RID 和 PhysicsServer3D 的 body RID 可能 index 完全相同(都是槽位 5、validator 42),因为它们各有各的 RID_Owner。把物理 RID 传给渲染 API 不会被"版本不匹配"拦住,而是被当成一个合法 RID 去查渲染的表——结果要么是 nullptr(运气好,槽位空着),要么是拿到一个类型错误的对象(运气差,槽位被占了)。后者不会立刻崩溃,而是表现为诡异的渲染错误,非常难查。
防范手段只有一个:严格遵守"RID 从哪个 server 来就回哪个 server 去"。4.x 在 debug 构建里给 RID_Alloc 加了类型标签检查(RID_Alloc 内部存一个 const char *type_name,get_or_null 时比对),能在踩坑时给出 "RID of type 'Mesh' used as 'Body'" 这种明确报错——但这个检查只在 debug 编译存在,release 构建为了零开销把它整个裁掉了。这也是"导出后行为不一致"类 bug 的一个已知来源。
本文代码引自 Godot 4.2.2-stable 的 core/templates/rid.h、core/templates/rid_owner.h,均有删减。