Godot 场景节点删掉了,RenderingServer RID 为什么还活着
小地图覆盖层已经从场景树移除,画面也不再显示它,RenderingServer 创建的 CanvasItem RID 却仍留在底层。下一次打开地图时,新的覆盖层正常出现,旧 RID 继续占着资源;若异步配置结果晚到,它甚至可能重新挂回画布。场景节点消失和渲染对象释放看似应当同步,实际上二者属于不同的生命周期。

节点退出并不会替你回收手工创建的 RID
场景树管理 Node,RenderingServer.canvas_item_create() 返回的 RID 则直接标识服务端对象。只要创建动作绕过了 CanvasItem 节点,释放责任也随之绕过场景树。把 RID 临时塞进脚本字段仍不够:配置失败、后发请求覆盖先发请求、节点销毁,都必须走到同一个释放所有者。
探针把这项责任收敛到 render_object_owner.gd 的 RenderObjectOwner。场景节点只能递增请求修订并提交候选参数;_active_rid、根 RID 与释放顺序不向调用者开放。关键实现如下,可直接放进 Godot 4 项目:
class_name RenderObjectOwner
extends RefCounted
enum CommitResult { COMMITTED, CONFIGURE_FAILED, STALE_REQUEST }
var _server
var _root_rid: RID
var _active_rid: RID
var _active_label := "none"
var _active_revision := 0
var _requested_revision := 0
func _init(server, root_rid: RID) -> void:
_server = server
_root_rid = root_rid
func request_rebuild() -> int:
_requested_revision += 1
return _requested_revision
func build_and_commit(revision: int, label: String, color: Color) -> CommitResult:
var candidate: RID = _server.create_canvas_item()
if not _server.configure_canvas_item(candidate, _root_rid, color):
_server.release(candidate)
return CommitResult.CONFIGURE_FAILED
if revision != _requested_revision:
_server.release(candidate)
return CommitResult.STALE_REQUEST
var previous := _active_rid
_active_rid = candidate
_active_label = label
_active_revision = revision
if previous.is_valid():
_server.release(previous)
return CommitResult.COMMITTED
func snapshot() -> Dictionary:
return {
"label": _active_label,
"revision": _active_revision,
"rid": _active_rid,
}
func dispose() -> void:
if _active_rid.is_valid():
_server.release(_active_rid)
_active_rid = RID()
if _root_rid.is_valid():
_server.release(_root_rid)
_root_rid = RID()
这里的顺序不能互换。新 RID 必须先完成父子关系与绘制命令配置,再检查修订号,最后才替换活动引用;旧 RID 在替换之后释放。若先释放旧对象,后续配置失败会让可见覆盖层出现空窗。若先写 _active_rid,场景树可能读到只创建、未配置的半成品。
_server 不是为了把 RenderingServer 再封装成一套通用渲染框架。它只隔离四个与本问题直接相关的动作:创建 CanvasItem、挂接父 RID、写入绘制命令和释放。生产适配器可以直接转发这些调用,测试适配器则额外记录存活集合。这样,关于“谁释放了哪个 RID”的断言不依赖渲染后端是否把已释放句柄立刻标成无效,也不会把实现细节误当成 Godot 的公开保证。
根 RID 与活动 RID 同属一个所有者也很重要。固定样本在稳定状态始终只有两个存活对象:一个承载覆盖层的根,一个是当前活动项。这个数量不是性能指标,而是生命周期约束;失败候选出现后若存活数升到 3 且不再下降,就已经能判定释放链断裂。相反,仅检查画面是否仍显示旧覆盖层,无法发现不可见资源泄漏。
失败候选也必须拥有一条完整的死亡路径
固定样本先把 RID 2 提交为 MapOverlayA@1。请求 2 创建 RID 3,但配置被注入失败;随后同一个请求的迟到结果创建 RID 4,而当前修订已经推进到 3。两条路径都释放自己的候选,不碰活动 RID 2。请求 3 的 RID 5 完整通过后,活动引用才切换为 MapOverlayB@3,随后回收 RID 2。

回归入口位于 main.gd::_ready,适配器实际调用 RenderingServer.canvas_item_create、canvas_item_set_parent、canvas_item_add_rect 与 free_rid,同时记录存活集合与释放顺序。当天执行结果为:
cd "$WORK_DIR"
"$GODOT_BIN" \
--headless --path .tmp/daily-labs/2026-08-21-1930
commit: request=1 result=COMMITTED active=MapOverlayA@1 live_rids=2
configure-failure: request=2 result=CONFIGURE_FAILED active=MapOverlayA@1 released_rid=3
stale: completed=2 requested=3 result=STALE_REQUEST active=MapOverlayA@1 released_rid=4
commit: request=3 result=COMMITTED active=MapOverlayB@3 released_previous_rid=2 live_rids=2
dispose: released_active=5 released_root=1 live_rids=0
tests: pass=16 fail=0 skip=0 duration_ms=1
编辑器导入和运行进程退出码均为 0;导入耗时 10190 毫秒,运行进程耗时 1080 毫秒。这里没有据此宣称性能收益,这组数据只证明脚本可加载、真实 RenderingServer 调用成立,并且五个 RID 最终按 [3,4,2,5,1] 全部释放。
所有权边界比 queue_free 更值得审查
一种常见替代是让节点在 _exit_tree() 里逐个释放 RID。它适合节点就是唯一创建者、没有异步候选的简单对象;一旦后台重建可以跨越节点生命周期,退出回调只能处理当时写入字段的活动 RID,无法处理尚未提交的候选。把候选表继续堆在节点上也能工作,但节点随后同时承担场景编排、修订仲裁和底层资源回收,边界会再次混合。
另一个容易被忽略的失败条件是重复销毁。dispose() 释放后立即把字段改回空 RID,因此节点退出、显式关闭地图和宿主清理即使汇合到同一方法,也不会再次释放已经交还给 RenderingServer 的对象。候选 RID 则从不写入活动字段:失败分支在返回前释放,成功分支把它转移给 _active_rid。这实际上是一条很小的线性所有权规则——一个 RID 要么仍是当前函数的候选,要么已经成为所有者的活动对象,不能同时处于两种状态。
若覆盖层后来扩展成多材质、多 CanvasItem 的对象组,演进触发点不是“RID 数量超过某个值”,而是一次可见切换需要同时提交多个低层对象。届时应把候选从单个 RID 提升为包含全部子 RID 的快照,并保持同样的修订检查和整体释放;不应让各子项分别写入活动状态,否则配置失败仍会暴露半套覆盖层。
当前方案仍有明确范围:探针验证的是 RID 身份与提交顺序,不包含 GPU 性能测试;真实项目若在线程间准备数据,仍须遵守引擎对 RenderingServer 调用线程的约束。它解决的是更基础的问题——无论请求成功、失败、迟到还是宿主节点销毁,每个手工创建的 RID 都只能有一个释放者,而且只有完整候选可以成为活动对象。