Godot 场景节点删掉了,RenderingServer RID 为什么还活着

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

场景树与 RenderingServer 之间的候选渲染对象边界

节点退出并不会替你回收手工创建的 RID

场景树管理 NodeRenderingServer.canvas_item_create() 返回的 RID 则直接标识服务端对象。只要创建动作绕过了 CanvasItem 节点,释放责任也随之绕过场景树。把 RID 临时塞进脚本字段仍不够:配置失败、后发请求覆盖先发请求、节点销毁,都必须走到同一个释放所有者。

探针把这项责任收敛到 render_object_owner.gdRenderObjectOwner。场景节点只能递增请求修订并提交候选参数;_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。

RID 候选配置、拒绝、替换和销毁的固定运行顺序

回归入口位于 main.gd::_ready,适配器实际调用 RenderingServer.canvas_item_createcanvas_item_set_parentcanvas_item_add_rectfree_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 都只能有一个释放者,而且只有完整候选可以成为活动对象。