Godot RenderingServer RID:创建失败必须回滚准备所有权
程序化地形节点进入场景树时,需要连续创建 Mesh、Material 与 Instance 三类底层资源。若前两个 RID 已创建而 Instance 失败,节点尚不可渲染,服务器却可能永久保留孤儿资源。证据标识 2026-08-02-2000 的最小 C# 探针把这条 Godot 集成边界压缩为可执行合同。
// .tmp/daily-labs/2026-08-02-2000/RenderObjectTransaction.cs
// RidKind / Rid / RenderObject
public enum RidKind { Mesh, Material, Instance }
public readonly record struct Rid(int Value, RidKind Kind);
public sealed record RenderObject(
Rid Mesh,
Rid Material,
Rid Instance
);
所有权边界
RenderingServer 是 RID 存活状态的事实源,场景节点只拥有已经完整提交的 RenderObject;创建过程中的 RID 归准备事务所有。两个不变量是:Current 非空时必须同时包含三类 RID;TryCreate 返回失败时,本次创建增加的活动 RID 数量必须为零。探针不复刻引擎内部实现,只验证调用者在 _Ready、流送回调或工具脚本中应维持的所有权协议。

逐次把 RID 写入节点字段看似更直接,却让“节点存在”与“渲染对象完整”成为两个可观察状态。随后无论退出场景树、热重载还是重试,都必须猜测哪些字段已经创建;清理分支数量会随资源种类增长。
准备事务
实现先把每个成功创建的 RID 放入 pending。三个分配全部完成后,才构造不可变的 RenderObject 并一次替换 Current;pending.Clear() 表示所有权从事务转移给节点。提交点之前,任何读取者都看不到半成品。
// .tmp/daily-labs/2026-08-02-2000/RenderObjectTransaction.cs
// SceneRenderNode.TryCreate
public bool TryCreate(Func<RidKind, bool> shouldFail)
{
var pending = new List<Rid>(3);
try
{
var mesh = Allocate(RidKind.Mesh, pending, shouldFail);
var material = Allocate(RidKind.Material, pending, shouldFail);
var instance = Allocate(RidKind.Instance, pending, shouldFail);
Current = new RenderObject(mesh, material, instance);
pending.Clear();
return true;
}
catch (InvalidOperationException)
{
for (var index = pending.Count - 1; index >= 0; index--)
server.Free(pending[index]);
return false;
}
}
正常时序是 Mesh、Material、Instance 依次进入准备列表,随后 Current 一次发布;节点退出时按 Instance、Material、Mesh 的反向依赖顺序释放。反向释放不是本探针的引擎性能结论,而是让清理顺序与构造顺序对称,便于加入真实依赖断言。
失败回滚
固定失败发生在 Instance 分配前。此时服务器有两个活动 RID,节点的 Current 仍为空;捕获失败后反向释放 Material 与 Mesh,活动数回到零。测试同时覆盖更早的 Material 失败,避免实现只对最后一步做特判。
// .tmp/daily-labs/2026-08-02-2000/Program.cs
// material / instance failure regression
Run("material failure rolls back mesh", () =>
{
var server = new FakeRenderingServer();
var node = new SceneRenderNode(server);
Equal(false, node.TryCreate(
kind => kind is RidKind.Material));
Equal(0, server.ActiveCount);
Equal(null, node.Current);
});
Run("instance failure rolls back reverse order", () =>
{
var server = new FakeRenderingServer();
var node = new SceneRenderNode(server);
Equal(false, node.TryCreate(
kind => kind is RidKind.Instance));
Equal(0, server.ActiveCount);
Equal(null, node.Current);
});

固定行为
探针使用 .NET 9 标准库模拟 RID 注册表,不引入外部包。Release 构建为 0 警告、0 错误;三个用例通过,失败 0、跳过 0,测试逻辑耗时 9.425 毫秒,完整运行进程耗时 1.19 秒,退出码为 0。时间只证明本次执行完成,不用于推断 Godot 帧耗。
$ cd "$WORK_DIR"
$ dotnet build \
.tmp/daily-labs/2026-08-02-2000/RenderObjectProbe.csproj \
-c Release --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:00.92
$ dotnet run \
--project .tmp/daily-labs/2026-08-02-2000/RenderObjectProbe.csproj \
-c Release --no-build --no-restore
PASS commit publishes complete RID set
PASS material failure rolls back mesh
PASS instance failure rolls back reverse order
tests=3 passed=3 failed=0 skipped=0 duration_ms=9.425
real 1.19
user 0.27
sys 0.10
exit_code=0
{
"evidenceId": "2026-08-02-2000",
"rejected": false,
"activeAfterReject": 0,
"accepted": true,
"activeAfterCommit": 3,
"mesh": 3,
"material": 4,
"instance": 5
}
固定序号从 3 开始,是因为失败事务已分配并回收 1、2;这证明释放 RID 不等于复用标识。调用者因此不能用整数相等推断资源生命周期,也不能把失败事务留下的旧 RID 重新附着到节点。
容量边界
准备列表空间复杂度为 O(r),其中 r 是单个节点创建的 RID 数;成功与失败路径都最多遍历一次列表。当前探针限定单线程、单节点、同步创建,不覆盖渲染线程命令排队、跨帧异步资源或服务器丢失。若创建跨越多帧,事务必须增加代际号和取消状态,防止旧回调向已复用节点提交。
success: active 0 -> 1 -> 2 -> 3, Current null -> complete
failure: active 0 -> 1 -> 2 -> 0, Current null -> null
commit condition = Mesh and Material and Instance all allocated
rollback result = active delta 0 and Current unchanged
为每个 RID 单独加空值检查能处理三项资源,却会在阴影实例、骨骼缓冲和多材质加入后形成组合爆炸。当前取舍增加一个短生命周期列表,换取统一回滚与单一提交点。演进触发条件不是 RID 数达到某个任意阈值,而是创建开始跨帧、存在并发取消,或需要在旧对象仍可见时热替换;届时应升级为带代际的双对象事务,而不是让 Current 暴露部分更新。
架构结论
场景节点与 RenderingServer 的边界不能以“调用创建函数成功过几次”定义,而应以完整渲染对象是否提交定义。准备事务暂时拥有所有新 RID,失败就清空本次增量,成功才一次交给节点;节点销毁只处理已提交对象。这个合同把孤儿资源、半可见节点和重试清理统一成同一个可测试判断,也为跨帧创建预留了明确的代际扩展点。