Godot 4 引擎架构 · 第 三 篇

服务层接口:RenderingServer 为什么是一组 RID 型命令

2026-10-0817,702 字39 张配图本文由@溯光 出品,转载请注明出处。

站点链接:技术博客页 | 公众号:溯光的图形笔记

题图:Godot 4 编辑器 2D 工作区界面
题图题图来自《Godot 引擎的 2D 工作区界面》@ Wikimedia Commons(作者 VicFic2006;MIT 许可,署名要求;原图未改动;图中工程为 Godot 官方示例。Godot 名称与 Logo 为商标,本处不暗示官方背书)

入口:服务层为什么必须能排队

这一章要回答:服务层(server layer)为什么不能像普通函数库那样随叫随到,非得把调用排进一条队列。

0.1 三层各持什么

这一节要回答:门面、实现、渲染器这三层各自握着什么,为什么它们压不成一层。

OK,把服务层拆开看,它分成三段。最上面是门面 RenderingServer,它对外只暴露方法签名,自己不干活;中间是实现 RenderingServerDefault,它拿着命令队列(command queue)与线程标识,负责决定一条调用当场执行还是排队;下面是各个渲染器模块,由全局注册表 RenderingServerGlobals 下辖,2D、3D、光照、材质、纹理各占一个成员。段与段之间靠一句句柄(RID)串起来,调用方拿到的从来是一张通行证,而非对象本身。

门面这一层有个反讽之处:它自己是一个 Object(对象),能挂进 ClassDB(引擎类库),也能被脚本直接调。官方教程 custom_godot_servers 把这件事写成 "Servers in Godot 4 are written in C++ with the new function binding system that allows them to be used in GDExtension."(官方文档,CC BY 3.0,署名 Juan Linietsky、Ariel Manzur 与 Godot 社区;本次仅节选,未改动文字)。服务层虽被称作低层 API,它的门面却完全按高层对象的规矩注册,能进类库也能被绑定。

一层入口、一个实现、一组渲染器:三层各持什么
图 3-1一层入口、一个实现、一组渲染器:三层各持什么@ 本系列绘制

言归正传,好了,读这张图:上格是门面,只列签名;中格是实现,握着队列与线程标识;下格是渲染器集合,若干模块并排挂在一个注册表下。三格之间那根竖线标着「句柄」,说明跨格的通行证不是指针。把这三格记住,后面每次问「这条命令此刻在哪一格」,答案都能落回其中一格。

三条线各管一段这件事还有一层含义。门面这一格是可被脚本触到的边界,实现那一格是线程边界的所在地,渲染器那一格是真正读写数据的地方。调一次 canvas_item_create(),信息依次穿过这三格,每一格都会给这个对象换一种身份:先是方法调用,再是队列里的一条命令,最后才是一张挂在某个 owner 名下的句柄。

0.2 排队这件事的动机

这一节要回答:服务层为什么必须能被排到另一条线程上执行,动机从哪里来。

说白了,动机是多线程。引擎作者 reduz 在 2016 年那篇《Why does Godot use Servers and RIDs?》里把服务器描述成一条单通道命令 API,把句柄描述成远端对象的凭据;逻辑、物理、渲染三条线各自推进,命令缓冲把跨线的调用攒起来。官方 RenderingServer 类参考也留了三条硬边界,逐字如下:"The rendering server is completely opaque: the internals are entirely implementation-specific and cannot be accessed.",以及 "These functions return RIDs which are not references to the objects themselves, but opaque pointers towards these objects.",还有 "These servers will often run asynchronously for performance and calling any function that returns a value will stall them."(官方文档,CC BY 3.0,本次仅节选)。

三条边界各自对应一件设计决定。完全不可访问决定了调用方只能拿句柄,摸不到对象本体;要返回值的命令会被打断(stall),决定了这类命令必须同步地等另一条线程跑完;服务器常常异步跑,决定了「要不要入队」得在运行期判,不能写死进编译结果。三件事合起来,就是「服务层必须能排队」这句话的全部内容。

言归正传,排队带来一种调用方要习惯的距离感。一条建资源的命令发出去,对象可能还没建好;一次查询发出去,可能要等另一条线程跑完。这段距离是拿性能换来的,也是本篇要走的整条主线。reduz 在博文里同时强调,这套设计不能牺牲易用性,脚本侧看到的仍然是一个个普通方法。

0.3 本篇边界与三层证据口径

这一节要回答:本篇把边界划在哪里,取证分几层,又按哪个版本与哪份快照读。

先把边界划出来。本篇只走「服务层接口为什么长成一组 RID 型命令」这一段:句柄的形状、句柄的编码、那道未初始化的门、is_valid() 的坑,以及记账、泄漏点名、一条命令的三种形态、要不要入队的运行期判断。剔除与排序、画布渲染抽象、批处理规则、RenderingDevice 的命令列表与依赖图、后端分叉、主循环与线程池的完整骨架,都不在本篇展开。

这一篇也不是孤立的一篇。本系列的第一篇把四层落点链先走通了一遍,第二篇接着把场景层的对象模型按职责归了类。全系列的目录按层摊开大致是这样:前两篇给全景与对象模型,第三篇起进服务层,讲门面与句柄;再往后是剔除与排序、渲染抽象、批处理与命令录制、渲染设备与后端分叉;最后两篇收在主循环的多线程与资源侧。本篇对应其中第三格,只把服务层接口这一层讲透。

取证分三层,口径分开陈述。第一层是官方文档,落在 docs.godotengine.org,回答官方想让引擎怎么工作;第二层是官方源码原文,落在 godot 仓库的 master 分支,回答代码实际上怎么组织,本篇引用的行号一律按 master 快照(2026-10-08)实读,共 94 处,机器核对 0 处失配。第三层是本机实测,落在工程里的四段探针,回答这台机器上实际跑了什么。三层各说各的,正文里不混写。

言归正传,三层证据的强弱不同,用法也跟着变。官方文档给得出设计意图与命名约定,对实现细节常只给概述;源码原文给得出确切行号与确切写法,它不解释作者为什么这样写;本机读数给得出这台机器上的实况,它只是一个样本。机制拿源码原文作证,意图拿官方文档作证,实况拿本机读数作证,这条分工本篇一直执行。

版本口径两版并存,别写混。本机实测全部跑在 4.8.dev.custom_build.7177493c1(2026-07-03 构建),这是自定义构建,不属于官方稳定版。正文引用本机读数时会写明版本串与「自定义构建」字样,不把它当成官方 stable 的行为,也不跨机器外推。

行号还有一条纪律。本机编辑器所据的提交是 7177493(2026-07-03),与 master 快照已经漂移,而且漂移量逐文件、逐段都不同,所以拿旧行号做算术平移走不通,必须逐条按字面量重新定位。

源码落点:服务层定位与三条边界见官方 RenderingServer 类参考,服务层的上下关系见 用服务器优化,自己写一个服务的官方最小例见 custom_godot_servers。其中: class_renderingserver 给出「渲染服务器完全不可访问」与「返回不透明指针」两句原句,using_servers 给出「服务层是地基、场景系统建在其上」的定位,custom_godot_servers 给出「服务层在 4.x 用新的函数绑定系统写成」这条口径,三者都属官方文档口径。

一、句柄的形状:RID 只有一个 uint64_t

这一章要回答:一张句柄在内存里到底占多大、能做什么,为什么它小到只剩一个整数。

1.1 句柄本体就是一个 64 位整数

这一节要回答:RID 这个类型里面装着什么,默认值又意味着什么。

OK,先看本体。core/templates/rid.h 第 38 行是 class RID {,第 39 行是 friend class RID_AllocBase;,第 40 行是 uint64_t _id = 0;。三行合起来说明:整个句柄就是一个 64 位无符号整数,默认取 0,而 _id 是 private 的,只有分配器(RID_AllocBase 那一支)能碰它的本体。调用方拿到的不是对象地址,官方把这种凭据叫作不透明指针(opaque pointer)。

句柄在服务层里是一切资源的通行证。纹理、网格、画布项、灯光,创建时都返回一张 RID;后续每条命令都拿着它去找对象。官方 RID 类参考把这一点写成 "RIDs are opaque, which means they do not grant access to the resource itself.",又说 "Any RID and its referenced object can be represented by multiple RIDs. RIDs are only guaranteed to be unique within one process."(官方文档,CC BY 3.0,本次仅节选)。前一句说不透明,后一句说同一对象可能对应多张 RID,并且只在单个进程内保证唯一,跨进程使用是一条架构上的硬限制。

rid.h 第 36 到 42 与第 61 到 74 行原文:class RID 的全部公开…
图 3-2rid.h 第 36 到 42 与第 61 到 74 行原文:class RID 的全部公开面@ godot master 快照(2026-10-08)原文截取 · MIT

言归正传,读这张图:面板左侧是 class RID 的定义行与那个 uint64_t _id,右侧是陆续出现的公开方法与比较运算符。整页里没有指针、没有虚函数表、没有任何指向堆的字段。一个 8 字节的整数就是全部家当,这一点是后面所有编码与校验的前提。

1.2 公开面只有七件事

这一节要回答:一个只有整数的类型,对外暴露了哪几件可做的事。

好了,把公开面数一遍。rid.h 里能对外看见的,一是比较(operator<,第 46 行),二是 is_valid()(第 61 行),三是 is_null()(第 62 行),四是 get_local_index()(第 64 行),五是 from_uint64()(第 66 行),六是 get_id()(第 71 行),七是 hash()(第 73 行)。七件事里,取原值的只有 get_id() 与 from_uint64(),取低位的只有 get_local_index(),其余是判空、判有效与哈希。

这七件事的分布很讲究。hash() 加上 operator< 意味着 RID 可以直接当 HashMap 与 TreeMap 的键,不必额外包一层;get_local_index() 只取低 32 位,说明编码里确实把低位留给了固定含义;is_valid() 与 is_null() 两个名字只判整数是否为零。公开面里没有任何「取对象」或「解引用」的方法,真要碰到对象,只能回到持有它的那个 owner 去要。

七件事里最容易被误读的是 is_valid()。名字里带 valid,读起来像「这个资源有效」,可它的实现只看整数非零。这处误读的代价要到第三章才完全显出来,这里先记一句:公开面给出的答复都是关于那个整数本身的,与整数背后是否还站着一个活对象无关。

RID 的公开面只有七件事:比较、判空、取低位、取原值、哈希,_id 是 private 且…
图 3-3RID 的公开面只有七件事:比较、判空、取低位、取原值、哈希,_id 是 private 且只对分配器开放@ 本系列绘制

言归正传,读这张图:七个方法按功能排成三列,比较与哈希一列、判空与判有效一列、取原值与取低位一列。右侧单独一格里标着 _id 是 private,只对分配器开放。这张图要留下的印象是:句柄能回答的问题只有「是不是零」「低位是多少」「原值是多少」,答复不了「背后那个对象还活着吗」。

1.3 句柄露出的地方,都只给号码

这一节要回答:这种底层凭据会不会露面,露出来的是什么。

先给结论,会露面,但露出来的只有一个数。脚本侧能拿到的只有 get_id() 返回的那串号码;本机的探针日志里,同一个数被拆成代际与槽位两列打出来;按官方口径,它「不授予对资源本身的访问」。三处给出的都只是号码本身。

句柄露出的三个地方都只给号码:界面上的属性字段、脚本里的取值、日志里的两列
图 3-4句柄露出的三个地方都只给号码:界面上的属性字段、脚本里的取值、日志里的两列@ 本系列绘制

读这张图:三行分别是界面上的属性字段、脚本里的取值、日志里的两列,每一行右侧写的都是「号码」,没有一行给出对象。所谓露出,是露出一个编号,不是露出编号背后站着的东西。

把 1.2 与 1.3 对起来看,两句结论同时成立:句柄在界面上看得见,看得见的部分却只是一个数。剩下的含义全在编码规则里,界面上不会标出来。这也解释了为什么光看界面调试时,两张不同的句柄长得很像,实际却是两个不同代际的对象。

源码落点:RID 本体与公开面见 rid.h。其中: 第 38 行是 class RID {,第 39 行是 friend class RID_AllocBase;,第 40 行是 uint64_t _id = 0;,第 46 行是比较运算符,第 61 行是 is_valid(),第 62 行是 is_null(),第 64 行是 get_local_index(),第 66 行是 from_uint64(),第 71 行是 get_id(),第 73 行是 hash(),行号均按 master 快照(2026-10-08)实读;不透明与进程内唯一的官方原句见 RID 类参考。

二、句柄的编码:id 等于代际接槽位

这一章要回答:那 64 位怎么切成两段,代际与槽位各占哪半边,中间又为什么夹着一道门。

2.1 编码三步

这一节要回答:一次分配时,那 64 位是按哪三步拼出来的。

言归正传,OK,看编码。core/templates/rid_owner.h 第 157 行是 uint32_t validator = 1 + (uint32_t)(_gen_id() % 0x7FFFFFFF);,第 158 行是 uint64_t id = validator;,第 159 行是 id <<= 32;,第 160 行是 id |= free_index;。四行就是编码的全部:先算出一个代际(generation),把它装进一个 64 位变量,左移 32 位挪到高位,再把槽位(slot)或进低位。写成算式就是 id = 代际 << 32 | 槽位。

三步里每一步都有含义。第一步的取模 0x7FFFFFFF 把代际限在 1..0x7FFFFFFE,既避开 0(0 留给空句柄),也避开最高位;第二步左移把代际搬到高 32 位;第三步把 free_index 放进低 32 位。一个 64 位数就这样同时装下了「第几代」与「第几个槽」两条信息,句柄自己带着一份可自证的来历。

这套编码解决了一个裸指针解决不了的问题。裸指针被释放后再指向别处,读的人无法察觉;带代际的句柄在槽位被复用时,高 32 位已经换代,旧句柄与新句柄一比即分。把「过期」这件事编码进句柄本身,是这一整章要立的骨架。

rid_owner.h 第 150 到 152 与第 157 到 165 行原文:代际取一个…
图 3-5rid_owner.h 第 150 到 152 与第 157 到 165 行原文:代际取一个数、搬到高 32 位、槽位或进低 32 位,并打上未初始化位@ godot master 快照(2026-10-08)原文截取 · MIT

读这张原文:157 行的 validator 算出代际并限域,158 行把它放进 id,159 行左移 32 位,160 行或上槽位下标。四行的次序不能换,先限域、再搬家、再填低位。把 id = 代际 << 32 | 槽位 这句记住,下面两张图都是在展开它。

2.2 位域的切法

这一节要回答:64 位里两段各占多宽,取值范围落在哪里。

好了,把 64 位摊平看。高 32 位是代际,取值域 1..0x7FFFFFFE,靠 _gen_id() 每分配一次递增一次,绕回也不落到 0 与最高位;低 32 位是槽位下标,get_local_index() 用一句 _id & 0xFFFFFFFF 就能取出来。代际那一半回答「第几次被复用」,槽位那一半回答「在哪个格子里」,两半各管一件事。

OK,两段的分工换来一条性质:同一个槽位被反复分配时,代际会变,句柄值也随之变。旧句柄的高 32 位停在过去那一代,与新句柄一比就不相等。这张「过期」信息就藏在句柄自己身上,不必再去查一张额外的时间表。上限也由两段定:槽位一侧受 owner 的元素上限约束,代际一侧循环 2 的 31 次方减二。

一个 RID 的位预算:高 32 位代际、低 32 位槽位,最高位暂作未初始化标记
图 3-6一个 RID 的位预算:高 32 位代际、低 32 位槽位,最高位暂作未初始化标记@ 本系列绘制

读这张位域图:上排 32 格写着代际,下排 32 格写着槽位,左侧标着取值范围 1..0x7FFFFFFE,右侧标着下标。图中那条 32 位的分界线是全部编码的骨架。记住这条线,看十六进制句柄时就能自己把两段拆开。

顺手记一处对照物。同样是 64 位,ObjectID 的用法完全不同:它把自己的最高位拿去标记「这是引用计数对象」(object_id.h 第 45 行),而判活靠场景层的全局表,与 RID 靠 owner 侧自证是两条不同的路。名字相近,预算用法不同。

2.3 两道门:置位与清位

这一节要回答:一个刚分配出来的句柄为什么还不能用,那道未初始化位又在哪几行被置被清。

说白了,分配与可用之间隔着一道门。rid_owner.h 第 163 行是 validator |= 0x80000000; //mark uninitialized bit,分配之后先给代际的最高位打上「未初始化」标记;第 234 行是 if (unlikely(p_initialize)) {,这是初始化路径与「只分配」路径的分岔;第 243 行是 c.validator &= 0x7FFFFFFF; //initialized,初始化时把这最高位清掉;第 247 行是一句 "Attempting to use an uninitialized RID",说明夹在两步之间拿句柄去用会被明确拒绝。

这四行把「建资源」拆成了两步:先分配一个槽位、拿到句柄,再初始化它。两步之间的句柄带着未初始化位,除了初始化本身,别的用途都会被挡。门面侧那条命令之所以拆成 allocate 加 initialize 两段,理由也在这里。拆开之后,第二步就能被搬到另一条线程去做,第一步仍然留在调用线程上,句柄先到手。

说白了,这道门还解释了另一件事:句柄在两步之间的状态是可观测的,但它对调用方基本透明,因为门面通常把两段连着一路走完。只有在跨线程的情况下,第二步被推进队列,这段带灯的时间才会短暂地暴露出来。

rid_owner.h 第 162 到 163 与第 234 到 249 行原文:分配时置未…
图 3-7rid_owner.h 第 162 到 163 与第 234 到 249 行原文:分配时置未初始化位、初始化时清位,两步之间用它会被明确拒绝@ godot master 快照(2026-10-08)原文截取 · MIT

读这张原文:163 行置位,234 行分岔,243 行清位,247 行是拒用的那句话。四处连起来是一条时间线:置位在前,清位在后,247 行管的是中间那段。把未初始化位想成一盏红灯,置上就是「先别用」,清掉才是「可以用」。

2.4 两道门之间夹着线程判据

这一节要回答:置位与清位之间除了等待,还挤进了什么。

把镜头拉近,两道门之间挤着一条线程判据。门面收到 canvas_item_create() 之后,实现层的宏先调用 allocate,拿到一个带未初始化位的句柄,然后问一句当前线程是不是服务线程;是就地清位,否就把清位这步连同句柄一起推进命令队列,交给服务线程去做。夹在两步之间的判据,决定了清位这一步落在谁的手上。

这道判据引出一个跨线程的注意点:如果初始化被排进队列,那么在服务线程真正清位之前,调用方手里的句柄一直停在未初始化态。调用方若在这段时间里拿它去用,232 行那条拒用逻辑照样生效,无论它在哪条线程。讲白了,句柄到手不等于可用,可用要等清位这一步落地。

OK,判据本身是一条很短的表达式,写成 Thread::get_caller_id() != server_thread。它的两个出口对应两条完全不同的执行路径,一条同步直调,一条排队异步。后面第七章会用本机两段探针分别把这两个出口跑一遍,这里先把分岔的位置记住。

分配与初始化之间夹着一句线程判据:取号、问线程,再决定直接初始化还是入队
图 3-8分配与初始化之间夹着一句线程判据:取号、问线程,再决定直接初始化还是入队@ 本系列绘制

读这张门图:左边是 allocate 那一格,右边是 initialize 那一格,两格中间横着一条判据「当前线程是不是服务线程」。判据的「是」出口直连右格,「否」出口先拐进队列再通向右格。图中最该记的是夹层:等待发生在队列里,而门是那道未初始化位。

2.5 探针 A:代际与槽位同步递增

这一节要回答:这台机器上连建若干资源,代际与槽位实际怎么走。

回到实况。用 4.8.dev.custom_build.7177493c1(自定义构建)跑探针 A,连建 6 个 canvas_item,读到的句柄依次是 RID(98784247808)、103079215105、107374182402、111669149699、115964116996、120259084293。把这些值拆开,高 32 位对应的代际是 23、24、25、26、27、28,低 32 位对应的槽位是 0、1、2、3、4、5。两段各自逐个递增,一步不落。

这组读数做了一件事:把 2.2 那张位域图从静态变成动态。代际那一半每建一个就加一,槽位那一半从 0 开始顺排,两条递进同向。它同时说明代际的起点是 23,前 22 代在这台机器的启动阶段已经被别的分配用掉了。步骤与枚举报出的次序一致,说明这段读数按 2.1 的四行原文如实编码。

本机探针 A 之一:连建 6 个 canvas_item,代际 23 到 28 逐个递增、槽…
图 3-9本机探针 A 之一:连建 6 个 canvas_item,代际 23 到 28 逐个递增、槽位 0 到 5 逐个递增@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

读这组读数:六行句柄,右侧并排拆出代际与槽位两列,两列都是等差递增。把第 1 行与第 6 行并看,代际差 5、槽位差 5,差值一致。这组数字是全篇唯一一次把「代际接槽位」这条编码摊在真实取值上,后面的复用顺序与悬空判据都从同一份探针来。

源码落点:编码与两道门见 rid_owner.h。其中: 第 157 至 160 行是代际限域与三步编码,第 163 行置未初始化位,第 234 行是初始化路径的分岔,第 243 行清位,第 247 行是拒用未初始化 RID 的文案,行号均按 master 快照(2026-10-08)实读;命令队列一侧的定义见 command_queue_mt.h,本机读数来自 4.8.dev.custom_build.7177493c1(自定义构建,2026-07-03 构建),只在「本机」口径下陈述。

三、那句话的坑:is_valid 不代表活着

下面进入这一章要回答:为什么一个句柄报「有效」却可能早已悬空,谁才是真正负责回答「它还活着吗」的那一方。

3.1 只判非 0 与连代际一起比

这一节要回答:is_valid() 与 owner 侧的 owns() 各按什么判,两者差在哪里。

OK,把两句判据并排看。rid.h 第 61 行是 bool is_valid() const { return _id != 0; },判据只有一个:这个 64 位数是不是 0。rid_owner.h 第 321 行是 bool owned = (…validator & 0x7FFFFFFF) == validator;,它把句柄切开,用低 32 位找槽位、用高 32 位比代际,两者都对上才算「这是我的」。前一句看得浅,后一句看得深。

差别就在代际。is_valid() 完全不看高 32 位,一个已经释放、槽位早已被别人的句柄,在它眼里照样非 0。owns() 拿代际一比,旧代际立刻露馅。这就解释了一件容易被忽略的事:句柄的有效性由持有它的那个模块回答,句柄自己不回答。同一份 RID 在另一个进程里也没有意义,官方把它列为一条根本性的架构限制。

这条「谁回答」的分工还有一层意义。ObjectID 的判活靠场景层的全局表,RID 的判活靠每个 owner 自己,所以服务层里各模块能各持一本账,互不牵连。判活责任下放到持有方,正是服务层能分家、能各自排队的前提。

rid.h 第 61 行与 rid_owner.h 第 302 到 328 行原文:is_v…
图 3-10rid.h 第 61 行与 rid_owner.h 第 302 到 328 行原文:is_valid 只判非 0,owns 连代际一起比@ godot master 快照(2026-10-08)原文截取 · MIT

读这张并置原文:左栏是 rid.h:61 那行单看非 0 的判据,右栏是 rid_owner.h:321 那行拆位比代的判据。两栏篇幅不对称,左边一行,右边一行加一句注释。两行放在同一张图上,差在哪一目了然:一个只看「是不是零」,一个还要看「是不是这一代」。

3.2 四种情形

这一节要回答:把建立、释放、复用、置空四种情况摆开,is_valid() 各报什么。

OK,好了,摆四种情形。第一种,刚 create 出来的句柄,is_valid() 报 true,它确实活着。第二种,已被 free 掉、槽位还没被别人复用的句柄,is_valid() 仍然报 true,此时它是一张悬空句柄(dangling handle),背后那块内存可能已被析构。第三种,已被 free 掉、槽位又被别人分走的句柄,is_valid() 还是 true,这次它指向的是一个别的对象,代际已变。第四种,空句柄 RID(),is_valid() 报 false。

四种里只有第一种与第四种能让 is_valid() 给出可靠答复。中间两种才是坑所在:报出来的是「有效」,实际是「过期」或「错指」。判据的强弱在这里分家,轻的判据给出轻的答案,重的判据(owns() 与 get_or_null())才有资格回答「这一代还是不是我的」。第三种情形比第二种更危险,因为它指向的对象是活的、可写的,出错时不会立刻显形。

is_valid 的四种情形对照:只有刚建与空句柄两种是安全的
图 3-11is_valid 的四种情形对照:只有刚建与空句柄两种是安全的@ 本系列绘制

读这张对照:四行对应四种情形,每行给出 is_valid() 的取值与一句「真正安全吗」的结论。第一行与第四行两列一致,中间两行出现分叉:is_valid() 报真,安全性报否。把这两行圈出来,它们是本篇最想让人记住的两格。

3.3 探针 A:已释放仍报有效

这一节要回答:这台机器上,已释放句柄的 is_valid() 实际读到了什么。

还是回到实况。依然是 4.8.dev.custom_build.7177493c1(自定义构建)的探针 A,把槽位 1 与槽位 3 那两个句柄释放之后,再读它们的 is_valid(),两个都仍然是 true;同一份探针里,空的 RID() 的 is_valid() 读到 false、get_id() 读到 0。释放没有让 is_valid() 改口。

这组读数把 3.2 的第二种情形落到本机。它不证明引擎有毛病,只说明「is_valid() 只看非 0」这条判据的含义就是这样:它回答「句柄是不是空」,不回答「对象是不是还在」。要问后者,得去 owner 侧。

本机探针 A 之三:已释放的 RID,is_valid 仍为 true;空句柄才为 fals…
图 3-12本机探针 A 之三:已释放的 RID,is_valid 仍为 true;空句柄才为 false@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

说白了,读这组读数:三行结果,前两行是已释放的句柄,is_valid() 列都是 true;第三行是空句柄,is_valid() 列为 false、get_id() 列为 0。三行读出同一个结论:释放这一步没有改动句柄本身的数值,改动的只是 owner 那一侧的账。

3.4 探针 A:报错落在第 578 行

这一节要回答:拿一张已释放的句柄去下命令,错误实际报在哪一行。

来试一次。拿已释放的那张句柄去调 canvas_item_set_parent,本机在 4.8.dev.custom_build.7177493c1(自定义构建)上读出这一行:ERROR: Parameter "canvas_item" is null.,落点是 RendererCanvasCull::canvas_item_set_parent (servers\rendering\renderer_canvas_cull.cpp:578),源码里对应的那句是 ERR_FAIL_NULL(canvas_item);。

报错的位置也有一处可讲。它没有落在门面,也没有落在 rid.h,最后落在渲染器内部那一句非空校验上。链条是这样的:调用方交来句柄,渲染器用 get_or_null() 按代际去查,代际对不上就返回空指针,接着第 578 行的非空校验把这次调用挡下并报错。代际校验把悬空句柄挡在了门外,报错只是它的回声。

把这处落点与 3.3 接起来看,一件事变得很具体:句柄在数值上没变,所以 is_valid() 说它有效;但它在 owner 的账上已经注销,所以真去用就撞上非空校验。两套答复来自两套判据,前者看数,后者看账。

本机探针 A 之四:拿已释放的 RID 去用,报错落在 renderer_canvas_cu…
图 3-13本机探针 A 之四:拿已释放的 RID 去用,报错落在 renderer_canvas_cull.cpp 第 578 行@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

读这组读数:一行错误文案,后面跟着文件名与第 578 行。把行号记下来,它是「用一张悬空句柄」这条路径的终点。报错发生在使用侧,释放侧此刻还没轮到,这一点是下一节两种命运的分界。

renderer_canvas_cull.cpp 第 576 到 578 行原文:get_o…
图 3-14renderer_canvas_cull.cpp 第 576 到 578 行原文:get_or_null 返回 nullptr 之后由调用方报错@ godot master 快照(2026-10-08)原文截取 · MIT

读这张原文:三行连起来是一条判定链。576 行是函数入口,577 行把句柄切开去 owner 里取对象,578 行在取不到时把那个 nullptr 变成报错。代际校验发生在第 577 行,报错只是它的后果;换一张代际相符的句柄,同一段代码安安静静走完。

3.5 同一张悬空句柄的两种命运

这一节要回答:同一张已释放的句柄,为什么用它报错、释放它却无声。

OK,回到那张句柄,看两条路径。第一条,拿它去下命令,走的是 get_or_null() 与第 578 行的非空校验,本机读到的就是 3.4 那行报错。第二条,拿它再 free_rid 一次,走的是 renderer_canvas_cull.cpp 第 2607 行那个 free();它内部的过滤判据在第 2637 行,写的是 else if (canvas_item_owner.owns(p_rid))。代际对不上,owns() 返回假,四路试探(utilities、light、canvas 等各一本账)都不认领它,于是这一次释放静默无事,什么都不做。

两条路径的差别全部来自判据落点。使用侧用 get_or_null() 把过期句柄判空,随后撞上非空校验而报错;释放侧用 owns() 先过滤,过期句柄过不了过滤,索性被忽略。本机对同一张 a[1] 实测,用它会报 3.4 那行错,再释放一次则无任何输出。同源的两条路径,一个出声,一个无声。

这对「两种命运」有一个实用推论。重复释放一张旧句柄不会酿成二次释放,因为过滤判据先把它挡下;真正需要提防的是继续拿旧句柄去下命令,那条路径没有静默过滤,只有一句报错。清理与使用,对同一张悬空句柄是两个不同的下场。

同一个悬空句柄的两种命运:用它去取对象会报错,用它去释放却静默无事
图 3-15同一个悬空句柄的两种命运:用它去取对象会报错,用它去释放却静默无事@ 本系列绘制

读这张对照:上方两条支路从同一张悬空句柄出发,左支经 get_or_null() 落到报错,右支经 owns() 落进空操作。两支共用同一代际判据,出口却相反。把这张图与 3.1 的两句判据接起来看,就能明白「谁在判」决定了「判完做什么」。

rendering_server_default.cpp 第 52 到 68 行原文:fre…
图 3-16rendering_server_default.cpp 第 52 到 68 行原文:free_rid 依次试四路 owner,认领靠代际@ godot master 快照(2026-10-08)原文截取 · MIT

读这张原文:free_rid 进来先试 utilities,再依次试 canvas、viewport、scene,每一路都先问一句 owns()。四路都问完还没有人认领,函数就走完了。释放侧不报错的道理全在这几行里:它没有一个「失败了」的出口,只有四个「不是我的」。

源码落点:判据与两条路径见 rid.h 与 renderer_canvas_cull.cpp。其中: rid.h:61 的 is_valid() 只判非 0,rid_owner.h:321 的 owns() 连代际一起比,使用侧报错落在 renderer_canvas_cull.cpp:578 的 ERR_FAIL_NULL(canvas_item);,释放侧过滤落在同文件 :2637 的 owns() 判据上,行号均按 master 快照(2026-10-08)实读;本机读数来自 4.8.dev.custom_build.7177493c1(自定义构建,2026-07-03 构建),只在「本机」口径下陈述。

四、谁记账:64 KiB 分块与同址的空闲栈

4.1 账本按块往外长

这一节要回答:句柄发出去之后,RID_Alloc 拿什么结构把已经占用的槽位记在账上。答案在 core/templates/rid_owner.h 里,分配器本体是 class RID_Alloc(rid_owner.h:92),它并不预先铺一块能装下全部槽位的定长数组,账本按块生长。

好了,往下看它按什么单位长。这里的最小单位叫数据块(chunk),定义与两条常量就挤在紧邻的几行里。

rid_owner.h 第 92 到 98 与第 100 到 103 与第 414 到 41…
图 3-17rid_owner.h 第 92 到 98 与第 100 到 103 与第 414 到 418 行原文:块与两张表、三个计数器、目标块 64 KiB@ godot master 快照(2026-10-08)原文截取 · MIT

说白了,先把这几行读给读者听。rid_owner.h:93 的 struct Chunk { T data; uint32_t validator; }; 说明一个块里装两样东西:元素本体 data 与这个位置的代际值 validator,两者同址同块,谁也没有单独放一份。rid_owner.h:414 的 p_target_chunk_byte_size = 65536 是构造参数的默认值,账本按 64 KiB 的目标块往外长。rid_owner.h:415 的 elements_in_chunk = sizeof(T) > p_target_chunk_byte_size ? 1 : (…) 说明一个块内能装几个元素,是拿块字节数除以 sizeof(T) 反推出来的,元素本身多大反过来决定一块能装几个。行号口径为 master(2026-10-08 快照)。这一节只讲结构,运行读数留到 4.4 节。

把这两条常量放到一起看,账本的开销形状就出来了:块的字节量级固定,块的数量随用量往外加。分配要拿新槽位时,先看当前块还有没有余位,没有再开下一个 64 KiB 的块,已经开出来的块不会被回收重排。中文侧的源码笔记多写成「用一个数组管理」,把这一层按块生长的账说掉了,而块字节数与元素数的这条反推关系正好是 4.2 节的题目。

还有一处细节要交代:块的字节目标是构造参数的默认值,RID_Owner 一族在建账本时把这个参数传进去,传什么都行,默认取 65536。这个数决定了每开一个块要问系统要多大一块连续内存,也决定了块数量随用量增长的斜率。它定的是内存开销的量级,不是槽位的容量上限,容量上限另有参数约束。把「块大小」与「块内条数」拆成两件事,是这一层账本能同时服务大小两类元素的原因。

源码落点:RID_Alloc 与 struct Chunk 见 rid_owner.h。其中:class RID_Alloc 在 rid_owner.h:92,struct Chunk { T data; uint32_t validator; }; 在 :93,目标块字节数 p_target_chunk_byte_size = 65536 在 :414,块内元素数的反推在 :415,行号口径为 master(2026-10-08 快照)。

4.2 块按字节定,元素数跟着反推

这一节要回答:为什么块大小按字节给,而不按条数给。按条数给会碰到同一类型在不同机器上大小不一的问题,块的内存占用没有稳定量级;按字节给,一块占多大就固定下来。

说白了,这个选择把「一块占多少内存」定死,代价是「一块装几条」变成一个随类型浮动的数。

块按字节定、元素数跟着反推:元素越大一块越少,每块开销压在同一个量级
图 3-18块按字节定、元素数跟着反推:元素越大一块越少,每块开销压在同一个量级@ 本系列绘制

说白了,推导本身只有一步除法:sizeof(T) 越小,elements_in_chunk 越大;sizeof(T) 一旦超过 65536,除法的结果被夹到 1,一块就只装一个元素。换一个大结构体进去,一个块等于一个元素,块字节目标在这里变成下限。反过来看,存指针的 RID_PtrOwner(rid_owner.h:458)持有的是 RID_Alloc<T *, THREAD_SAFE>,每个元素只有一个指针那么大,一块能装的槽位就多得多。同一个账本类服务两种粒度,靠的就是 rid_owner.h:415 这一行除法。

把两种拥有者的元素大小摆一起,差别的来源就更直白。值语义的 RID_Owner(rid_owner.h:517)存的是对象本体,sizeof(T) 可能几十到几百字节,一块 64 KiB 装几十到上千个;指针语义的 RID_PtrOwner(:458)存的是 T *,元素固定是 8 字节,一块能装几千个槽位。同一行除法,两种粒度,块数增长的快慢随类型而变。

还有一处值得对照:rid.h 前面的 RID_AllocBase 只是前置声明(rid.h:36),真正按块记账的全在 RID_Alloc 这一层,RID_Owner 与 RID_PtrOwner 是它的两个对外门面,一个管值语义,一个管指针语义。两种语义共用同一套块与栈,差别只在元素类型。这一节给的是源码推导,与 4.1 节两条常量连起来读即可,不必引读数。

源码落点:RID_Owner 与 RID_PtrOwner 见 rid_owner.h,前置声明见 rid.h。其中:块内元素数反推在 rid_owner.h:415,块字节目标在 :414,class RID_Owner 在 :517,class RID_PtrOwner 在 :458,class RID_AllocBase; 前置声明在 rid.h:36。

4.3 空闲表与分配同址,于是它是一张栈

这一节要回答:被释放的槽位下标写回哪里,下一次分配又从哪里取。答案就落在读与写那两行的同一个下标表达式上。

往下看这两行,它们算下标的方式一模一样。

rid_owner.h 第 152 与第 360 到 364 行原文:分配读那个位置、释放写…
图 3-19rid_owner.h 第 152 与第 360 到 364 行原文:分配读那个位置、释放写那个位置,空闲表按栈工作@ godot master 快照(2026-10-08)原文截取 · MIT

rid_owner.h:152 的 uint32_t free_index = free_list_chunks[alloc_count / elements_in_chunk][alloc_count % elements_in_chunk]; 是分配那一步:用 alloc_count 先换算成块下标,再换算成块内偏移,从那一格里取出一个可用槽位下标。rid_owner.h:364 的 free_list_chunks[alloc_count / elements_in_chunk][alloc_count % elements_in_chunk] = idx; 是释放那一步:把刚腾出来的 idx 写回同一格。读与写落在同一个下标位置,取的时候拿走的正是上一次放进去的那个,这张空闲栈(free list)在内存布局上是一张表,用起来是后进先出。alloc_count(rid_owner.h:102 的 uint32_t alloc_count = 0;)在这个设计里兼任两个角色,它既是活着的条数,也是这张栈的栈顶指针。槽位表按块组织,落在 free_list_chunks(:98)上,与数据块一一对应。

说白了,把 4.2 节的除法套进来,分配与释放走的是完全相同的两步换算,alloc_count 每次增减一格,栈顶跟着平移一格。释放把槽位压回栈顶,分配从栈顶取走,中间没有排序也没有查找。后释放的先被复用这条推论,就藏在这两行一模一样的下标表达式里。中文侧常把空闲表描述成「先到先得」的池子,本机读数与这个说法对不上,4.4 节用一组实测把顺序摆出来。

再补一句交换过的位置:分配那一步取走的是由 alloc_count 现算出来的那一格,因此不需要额外维护一个栈顶变量。释放把 idx 写回同一格,下一轮分配现算出来的下标仍然落在这一格上。整张空闲表没有做过排序,槽位之间没有优先级,被复用的顺序只由释放的先后决定。

源码落点:分配取栈顶与释放压回见 rid_owner.h。其中:分配取用 free_index 在 rid_owner.h:152,释放写回同一位置在 :364,活着的条数 alloc_count 在 :102,空闲槽位表 free_list_chunks 在 :98。

4.4 本机实测:释放 1、3 之后依次拿到 3、1、6

这一节要回答:后释放的先复用这条推论,在本机能不能被直接测出来。探针 A 的做法是连建 6 个 canvas_item,释放其中两个,再建 3 个,看新句柄落在哪些槽位上。

OK,读数落在这里:先建 6 个,槽位从 0 排到 5、代际从 23 排到 28;接着释放槽位 1 与槽位 3;再建 3 个,新句柄依次拿到槽位 3(代际 29)、槽位 1(代际 30)、槽位 6(代际 31)。

本机探针 A 之二:释放槽位 1 与 3 之后,新拿到的依次是 3、1、6
图 3-20本机探针 A 之二:释放槽位 1 与 3 之后,新拿到的依次是 3、1、6@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

口径要写清楚:这组读数跑在本机 4.8.dev.custom_build.7177493c1(2026-07-03 构建),这是一个自定义构建,不等于官方稳定版;本节只陈述本机现场,不跨机器比,也不给绝对性能承诺。读数里最该停留的是顺序:先释放 1 再释放 3,前两次复用先给 3 后给 1,第三次才在 5 之后开出一个新槽位 6。这一串正好对上 4.3 节那两行同址读写,第 6 个槽位是栈被取空之后新开数据块里的一格。代际那一路始终往上走(29、30、31),槽位那一路回到已经被释放的位置,两条线各管各的,这一点在第 2 章已经交代过。

说白了,对照第 2 章的记录:代际与槽位是两个独立递进的数,连建 6 个时两者同步往上,释放后重建时槽位回退、代际继续往上。本节的顺序读数正好把「槽位回退」这条坐实,回退的顺序是后进先出,与槽位号从小到大的顺序无关。

有一处边界要说清:本机这组读数是在 headless 下取的,句柄照样申请、照样可取,与渲染后端无关,所以这组数字不依赖画面。它证明的是分配顺序,不涉及任何绘制行为,把它读成「渲染器的工作量」会偏。

源码落点:分配取栈顶与释放压回见 rid_owner.h,探针脚本与日志见本机归档 evidence/probe/(rid_probe.gd / A_rid_headless.txt)。其中:分配取栈顶在 rid_owner.h:152,释放压回栈顶在 :364,块内元素数反推在 :415,本机实测输出见 A_rid_headless.txt,版本串 4.8.dev.custom_build.7177493c1 取自探针日志首行。

五、漏了会被点名:两级泄漏报告

5.1 析构时的兜底点名

这一节要回答:忘了释放的句柄,最后被谁发现、报了哪一级。第一处点名在分配器自己的析构里,属于兜底的那一级。

往下看源码那三行。这里写出来的是 ERROR 版,属于源码里备着、本场景未触发的一条。

rid_owner.h 第 371 到 374 与第 424 到 442 行原文:owner…
图 3-21rid_owner.h 第 371 到 374 与第 424 到 442 行原文:owner 侧的账本与析构时的兜底点名@ godot master 快照(2026-10-08)原文截取 · MIT

rid_owner.h:429 到 :431 是 RID_Alloc 析构时的一段收尾代码,形状是先判 alloc_count 是否还有余额,再对每种类型打一条 print_error,文案是 ERROR: %d RID allocations of type '%s' were leaked at exit.,第一个占位是还剩多少条,第二个是这个账本登记的类型描述。它管的事情叫泄漏点名(leak report),按类型逐个报,不精确到具体槽位。alloc_count 在 4.3 节出现过一次,这里它换了个用法,既是栈顶指针,也是这张账本还剩多少条活着的计数。类型描述由 set_description(rid_owner.h:563)在构造期设进去,rendering_device.h 里逐类都设了标签。要强调一句:这三行是源码原文,写的是析构路径上会怎么报,它的报出条件是 RID_Alloc 真的被析构而账上还有余额。它是不是本机跑出来的那句,要看 5.2 节的实测。

另一处要对照的是 get_rid_count()(rid_owner.h:552)与 get_owned_list()(:556):前者给出账上还剩多少条,后者把活着的句柄全列出来。第一级点名用的正是后者,账本侧的这两个接口因此不是摆设,它们是两级报告各自的数据来源。析构这一级只用得上前者,一个总数加一个类型描述,报出来就这么多。

源码落点:析构点名与类型描述见 rid_owner.h。其中:print_error 那三行在 rid_owner.h:429 到 :431,类型描述设置 set_description 在 :563,alloc_count 声明在 :102,账本侧清单 get_owned_list() 在 :556,行号口径为 master(2026-10-08 快照)。

5.2 本机实际听到的是 WARNING 版

这一节要回答:本机跑一遍泄漏场景,实际报出来的是哪一条,报完又做了什么。答案落在渲染器自己的收尾代码里,报的是 WARNING 版。

好了,把探针 B 的输出摆出来。

本机探针 B:漏掉不释放,报出来的是渲染器自己的 WARNING 版,随后又被强制清理
图 3-22本机探针 B:漏掉不释放,报出来的是渲染器自己的 WARNING 版,随后又被强制清理@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

说白了,读数是这样:建 3 个 canvas 与 5 个 canvas_item,全部不释放,退出时看到 WARNING: 3 RIDs of type "Canvas" were leaked. 与 WARNING: 5 RIDs of type "CanvasItem" were leaked.,调用点标在 RendererCanvasCull::_free_rids (servers\rendering\renderer_canvas_cull.cpp:2735);对照组里 2 个已经释放的句柄没有被点名。口径照旧:本机 4.8.dev.custom_build.7177493c1(2026-07-03 构建),自定义构建,不等于官方 stable,只作本机样本。最要紧的一句是:5.1 节那条 ERROR: … were leaked at exit. 在本场景没有出现。本机听到的是排序在前的那一级,报完还顺手把剩下的清掉。把源码里那段当成运行结果,会得出错误的结论。

顺带说清两级的差别:渲染器认识自己管的每一类资源,它能按类型把人叫出来,还知道怎么清理;账本只认识「还剩几条」,它只能给一个总数。本机这组读数停在有名字的那一级,正好说明在一个正常收尾的进程里,渲染器先把账结掉了,兜底那句没有机会出声。

再对比一下两级的形态:ERROR 版给的是一个类型加一个总数,WARNING 版给的是「多少条、哪种类型、在哪一行」三件一起。本机这组读数里 Canvas 报 3、CanvasItem 报 5,与探针建的数量一一对上,没有多报也没有漏报。已经释放过的 2 个句柄没被点名,说明点名只针对账上还挂着的那些。

源码落点:_free_rids 与文案见 renderer_canvas_cull.cpp,析构点名见 rid_owner.h。其中:WARNING 文案在 renderer_canvas_cull.cpp:2735,_free_rids 入口在 :2729,ERROR 版在 rid_owner.h:430,本机实测输出见 evidence/probe/B_leak_headless.txt。

5.3 两级路径的先后

这一节要回答:两级报告在时序上谁先谁后,各自由什么触发。第一级由渲染器的收尾驱动,第二级由账本自身的析构驱动,前者在前。

往下把链条摆平。

泄漏报告的两级:先报的那一级会顺手把账平了,兜底那一级因此没有触发
图 3-23泄漏报告的两级:先报的那一级会顺手把账平了,兜底那一级因此没有触发@ 本系列绘制

第一级的入口是 RendererCanvasCull::finalize()(renderer_canvas_cull.cpp:2743),它把五个 owner 逐个交给 _free_rids(:2729),点名靠的是 owner 侧的全量清单 get_owned_list()(:2730),报完在 :2735 打 WARNING,接着把认领到的句柄强制清理。finalize() 的调用点在 rendering_server_default.cpp:267,由服务层的 _finish()(:262)在收尾时触发。第一级走完,账上通常已经空了。第二级是 RID_Alloc 析构时的兜底点名(rid_owner.h:430),只有在第一级没把余额清完的情况下才会出声。

OK,为什么要分两级放,源码布局给了答案:渲染器的 finalize() 知道自己手里有哪几个 owner,能挨个走一遍;账本在析构时已经没有上层的语境,只剩一个计数,报出来的信息量到类型为止。两级都指向同一件事,漏掉的句柄到不了进程之外,它会在收尾的路上被叫住。本机实测停在第一级,第二级在源码里备着,这一节的任务就是把「哪句被听到、哪句被备着」分开写清。

renderer_canvas_cull.cpp 第 2729 到 2741 与第 2743…
图 3-24renderer_canvas_cull.cpp 第 2729 到 2741 与第 2743 到 2749 行原文:逐本账点名的那段与调起它的 finalize@ godot master 快照(2026-10-08)原文截取 · MIT

读这张原文:2729 到 2741 行是点名的那段,2730 行先取 owner 的全量清单,2733 与 2735 行按单复数分别报一句 WARNING,2737 到 2739 行逐条释放。2743 到 2749 行是调用方 finalize(),把五本账挨个交给它。清单那一行是整段的关键:报得出「几条」,是因为 owner 手里一直记着「哪些还活着」。

两级各自还有一处分工:第一级跑在渲染器的 finalize() 里,它知道五个 owner 分别是谁,所以它报得细;第二级跑在 RID_Alloc 的析构里,它只知道一个 alloc_count 与一个类型描述,所以它报得粗。细的那级报完能顺手清理,粗的那级只负责最后喊一声。本机实测停在细的那级,说明收尾流程按预期把账结掉了。

源码落点:收尾调用链见 rendering_server_default.cpp 与 renderer_canvas_cull.cpp。其中:_finish() 在 rendering_server_default.cpp:262,RSG::canvas->finalize(); 在 :267,finalize() 在 renderer_canvas_cull.cpp:2743,五个 owner 逐个点名在 :2744 到 :2748。

六、一条命令的三种形态

6.1 门面自己是一个 Object

这一节要回答:脚本里写 RenderingServer.canvas_item_create(),这条路从哪个类出发。出发点在 servers/rendering/rendering_server.h,服务层的门面(facade)本身就是一个 Object。

往下看这三处。

rendering_server.h 第 64 到 70 与第 106 到 108 与第 8…
图 3-25rendering_server.h 第 64 到 70 与第 106 到 108 与第 814 行原文:门面自己是一个 Object,命令是纯虚的@ godot master 快照(2026-10-08)原文截取 · MIT

rendering_server.h:66 的 class RenderingServer : public Object { 说明这个门面继承自 Object,能进 ClassDB、能被脚本直接调。:69 的 static RenderingServer *singleton; 是单例入口,脚本侧 RenderingServer 这个名字指的就是它。:814 的 virtual RID canvas_item_create() = 0; 是一条纯虚接口,返回值是一个 RID,门面上只给形状、不给实现。官方把这一层叫「低层 API」,可它偏偏留了一处例外:门面自己是个能进对象系统的 Object。这个例外的用处就在这里,脚本想绕开场景层直接下发命令,走的正是这条 Object 通道。

命令的实现落在哪、canvas_item_create 这个名字有没有对应到具体函数,要看 6.2 节的三族宏。门面这层与实现层之间隔着一族宏,命令的形状由门面定,路由由宏定,两者合起来才是本节标题里的那一条命令。

一处容易读错的地方:门面是 Object,实现层也是 Object,但两者处在对象系统里的不同层。门面这层只放接口与单例,命令真正执行的地方在实现层,两者之间靠工厂函数指针 create_func(rendering_server.cpp:45)与创建入口 create()(:51)接起来。脚本看到的是门面,跑起来的却是实现层,两层的类名不同、职责不同,只是都姓 Object。

源码落点:门面本体与单例见 rendering_server.h,创建入口见 rendering_server.cpp。其中:class RenderingServer : public Object { 在 rendering_server.h:66,singleton 在 :69,纯虚 canvas_item_create 在 :814,脚本绑定 bind_method 在 rendering_server.cpp:3330。

6.2 三族宏

这一节要回答:实现层怎么把成百条命令一次声明完,这些命令又被分成几族。答案在三行宏定义里,一族一条路由。

往下看这三条 #define。

server_wrap_mt_common.h 第 44 到 56 与第 69 到 73 行…
图 3-26server_wrap_mt_common.h 第 44 到 56 与第 69 到 73 行原文:要返回值的族与只交句柄的族@ godot master 快照(2026-10-08)原文截取 · MIT

server_wrap_mt_common.h:44 的 #define FUNC0R(m_r, m_type) 管的是要返回值的命令;:58 的 #define FUNCRIDSPLIT(m_type) 管的是要建资源的命令;:70 的 #define FUNCRID(m_type) 管的是只交句柄、不返回值的命令。三族同名不同形,靠宏把同一套骨架套在成百个方法名上,实现层因此不必逐条手写转发。rendering_server_default.h:1016 的 FUNCRIDSPLIT(canvas_item) 就是其中一条使用点,一行宏声明了整整一族 canvas_item_* 命令。

OK,三族的分野按「要不要回话」来切,切法与官方文档那条「任何返回值的调用都会让服务层把待办处理完」的边界对得上。这一节只摆族谱,各族具体走什么路放在 6.3 节。中文侧常把服务层的方法当成一组等价的函数,把这一层按族分类说掉,而族的划分恰好是后面线程分岔的前提。

三族之外还有两条与 ASYNC_COND_PUSH 同源的宏(rendering_server_default.h:117 到 :119),它们与命令族配合,决定一条命令要不要入队。宏名的后缀变化只影响回话那一步怎么取结果,不影响 FUNC0R 这一族「必须入队并等」的定位。把宏名与路由记住,读到 rendering_server_default.h 里成片的方法声明时就不会迷路。

源码落点:三族宏见 server_wrap_mt_common.h,宏的使用点见 rendering_server_default.h。其中:#define FUNC0R(m_r, m_type) 在 server_wrap_mt_common.h:44,#define FUNCRIDSPLIT(m_type) 在 :58,#define FUNCRID(m_type) 在 :70,FUNCRIDSPLIT(canvas_item) 的使用点在 rendering_server_default.h:1016。

6.3 FUNCRIDSPLIT 的两步与三族的分岔

这一节要回答:要返回值、建资源、只交句柄这三种命令各自走什么路。分岔写在宏体里,读宏体等于读路由表。

OK,先把三族的路摆开。

三族宏各自管一类命令:要不要值、要不要建对象,决定走哪条路
图 3-27三族宏各自管一类命令:要不要值、要不要建对象,决定走哪条路@ 本系列绘制

第一族 FUNC0R(server_wrap_mt_common.h:44)要回话,做法是把命令与一块放返回值的位置一起塞进命令队列,用的是 command_queue.push_and_ret(…)(:48),塞完就等对面把结果写回来,所以这一族的调用会把调用线程停下。第三族 FUNCRID(:70)只交出句柄、不取值,宏体直接把调用转给实现,不排队,紧邻的注释写着 //RID now returns directly, ensure thread safety yourself,线程安全留给调用方自己兜住。第二族 FUNCRIDSPLIT(:58)落在两者之间,它要建一个资源,而建资源被拆成两步:先 allocate 拿到一个句柄,再 initialize 把对象填进去,两步之间夹着一条线程判据 ASYNC_COND_PUSH(:61)。

接着把 FUNCRIDSPLIT 整段展开体读一遍。

server_wrap_mt_common.h 第 58 到 67 行原文与它的展开体:门面…
图 3-28server_wrap_mt_common.h 第 58 到 67 行原文与它的展开体:门面的 create 是两步加一句线程判据@ godot master 快照(2026-10-08)原文截取 · MIT

server_wrap_mt_common.h:58 到 :67 的宏体分几段::60 的 RID ret = server_name->m_type##_allocate(); 先调 allocate 拿句柄,这一步同时把槽位占住;:61 的 ASYNC_COND_PUSH 判当前线程是不是服务线程;命中就走入队那一支,把 _initialize 排进去;不命中就直接在 :64 附近调 _initialize。整段展开之后,外面看到的是一门 create,里面实际跑的是 allocate 加 initialize 两段,中间那条线程判据决定第二段在哪条线程上跑完。这就是宏展开(macro expansion)在这里的意义:一行 FUNCRIDSPLIT(canvas_item) 会被摊成这么一坨代码,再分摊到每一个 canvas_item_* 建资源的命令上。

OK,把三族并排看,分野只有一个判据:这条命令的回话需求有多强。要值的必须排队等,不等的只有那些不回话、只交句柄的命令,而建资源这一类卡在中间,它需要两步,于是把线程判据夹在两步之间。第 7 章会把那条判据本身摊开讲。

三族里最值得多看一眼的是第二族,因为它的两步与第 2 章那道门对得上:allocate 之后代际的最高位还标着未初始化(rid_owner.h:163),initialize 才把这一位清掉(:243)。第一族与第三族的差别在回话,第二族的差别在它把建资源这件事切成两半,而切点正好落在句柄可用与不可用之间。这条切法一旦看懂,门面上那门 create 由一行宏声明就不再奇怪。

源码落点:FUNCRIDSPLIT 展开体见 server_wrap_mt_common.h,push_and_ret 的定义见 command_queue_mt.h。其中:#define FUNCRIDSPLIT(m_type) 在 server_wrap_mt_common.h:58,RID ret = …_allocate(); 在 :60,ASYNC_COND_PUSH 分支在 :61,_initialize 调用在 :64,命令队列 push_and_ret 在 command_queue_mt.h:245。

6.4 脚本里那条命令

这一节要回答:这些命令在编辑器里以什么形态对脚本露出。答案是它们在 ClassDB 里注册成了方法,脚本编辑器里能直接点出来。

好了,把脚本编辑器那张实拍摆上。

编辑器脚本工作区实拍:脚本里能直接调 RenderingServer 的那些方法
图 3-29编辑器脚本工作区实拍:脚本里能直接调 RenderingServer 的那些方法@ Wikimedia Commons · VicFic2006(MIT,原图未改动)

脚本侧的绑定落在 rendering_server.cpp:_bind_methods()(:2254)逐条注册,canvas_item_create 在 :3330 绑成 ClassDB::bind_method,释放入口 free_rid 在 :3520,跨线程入口 call_on_render_thread 在 :3597。绑好的方法在脚本编辑器里以补全项与文档提示的形式出现,写 RenderingServer. 就会列出这一串。这一族方法里,建资源的那些返回 RID,free_rid 收一个 RID,call_on_render_thread 收一个可调用对象,三条口子各自对着 6.2 节的一族宏。外部实拍取自 Wikimedia Commons 的 Category:Screenshots of Godot (game engine),该池以 CC BY-SA 4.0 为主,引用时须写明原图许可与本处改动;Godot 名称与标志是商标,图注不得暗示官方背书。这一节的作用是让读者确认:前面几节讲的命令,在编辑器里真的能被脚本点出来,不局限于源码层。

好了,还有一处顺带说清:脚本侧注册的方法名与 C++ 侧同名,canvas_item_create、free_rid、call_on_render_thread 在两边写法一致,脚本里读到的名字就是头文件里的名字。这一致性来自 _bind_methods() 里逐条 bind_method 的登记,登记表是逐行手写的,没有依赖自动反射。

源码落点:脚本绑定见 rendering_server.cpp,类参考见 class_renderingserver。其中:_bind_methods() 在 rendering_server.cpp:2254,canvas_item_create 绑定在 :3330,free_rid 绑定在 :3520,call_on_render_thread 绑定在 :3597,外部实拍候选与许可说明见本篇 material-inventory.md 的候选短名单。

七、要不要入队是运行期判断

上一章把三族命令的分岔认清了,这一章顺着那条异步分支往里走:命令到底在哪一刻被推进对列,判据写在哪里,两个分支在本机各长什么样。答案落在实现层的一枚布尔与一条宏判据上,和类名没有关系。

7.1 一个类、一条队列、一个布尔、一条判据

这一节要回答:实现层手里到底握着哪几样东西,线程边界凭什么立起来。把五行原文直接摊开,servers/rendering/rendering_server_default.h:47 是 class RenderingServerDefault : public RenderingServer {,:80 是 mutable CommandQueueMT command_queue;,:82 是 Thread::ID server_thread = Thread::MAIN_ID;,:85 是 bool create_thread = false;,:117 是 #define ASYNC_COND_PUSH (Thread::get_caller_id() != server_thread)。一个类、一条命令队列(command queue)、一个服务线程(server thread)标识、一个运行期布尔(runtime flag)、一条宏判据,五行摆齐,实现层的全部机关就在这里。

rendering_server_default.h 第 47 到 48 与第 80 到 8…
图 3-30rendering_server_default.h 第 47 到 48 与第 80 到 85 与第 116 到 119 与第 1230 到 1237 行原文:一个类、一条队列、一个布尔、一条判据@ godot master 快照(2026-10-08)原文截取 · MIT

把这块面板读给读者听,五行各守一段。第一行确认实现层只有一个类型;第二行说明队列是它自己的成员,不向外面借;第三行把服务线程标识默认钉在主线程;第四行是那个开关,默认关着;第五行是判据本体,问的是当前调用线程是不是服务线程。判据只做一次比较,Thread::get_caller_id() != server_thread,两边不等就为真,真就入队,假就当场执行。队列自身的定义在 core/templates/command_queue_mt.h:40 的 class CommandQueueMT {,压栈、冲刷与同步取回三件事都由它管。这里顺带交代一个名字:正文里常见的 RSG 是宏别名,servers/rendering/rendering_server_globals.h:70 写着 #define RSG RenderingServerGlobals,本体是同文件 :49 的 class RenderingServerGlobals,它是一个类,不是命名空间;后文凡出现 RSG::canvas,都是这枚宏展开之后的样子。

7.2 分岔点写在每条命令的宏里

好了,这一节要回答:既然实现层只有一个类,单线程与多线程两种走法又在哪里分开。翻创建点就清楚了,main/main.cpp:772 是 rendering_server = memnew(RenderingServerDefault());,无参,走单线程;:3591 是 memnew(RenderingServerDefault(OS::get_singleton()->is_separate_thread_rendering_enabled())),同一个类,只是把运行期开关当参数递进去。开关进去之后落到全局注册表,servers/rendering/rendering_server_default.cpp:241 的 RSG::threaded = create_thread; 把它写进 rendering_server_globals.h:51 的 static inline bool threaded = false;。

单线程与多线程不是两个类:同一个实现类加一个运行期布尔,判据写在每条命令的宏里
图 3-31单线程与多线程不是两个类:同一个实现类加一个运行期布尔,判据写在每条命令的宏里@ 本系列绘制

这张图把两个创建点收在同一个类上,再把开关的去向画到全局注册表。换个角度说,所谓「换了个实现」,落到源码上只是同一个构造点的两种参数。真正的分岔发生在执行期::117 那条 ASYNC_COND_PUSH 与它的两条同源宏(:118 的 ..._AND_RET、:119 的 ..._AND_SYNC)在每条命令里被调一次,调用线程等于服务线程就同步直调,不等就压进 command_queue。命令本身带的是句柄(RID),排队排的也是它,所以跨线程不需要搬对象。同步那一支的落点看得见,rendering_server_default.h:296 等处是 command_queue.flush_if_pending();,先把待办冲刷干净再直接调实现层。官方文档把这条边界写成一句:渲染服务的实际逻辑可能跑在另一条线程上,从主线程或任何别的线程去碰它的内部会出错。call_on_render_thread 自己也有显式分岔,rendering_server_default.h:1230–1237 一处按同一枚布尔走两条路。

7.3 两个分支各自的本机读数

这一节要回答:同步直调与入队这两支,在本机各跑出什么样子。先把读数摆上桌,这一组读数出自本机二进制 4.8.dev.custom_build.7177493c1(2026-07-03 构建),是一个自定义构建,与官方稳定版不等同,只在本机口径下成立。探针以 --render-thread safe 启动 thread_probe.tscn,主线程 id 读到 1,call_on_render_thread 的回调在当帧当场执行,回调里读到的线程 id 也是 1。

本机探针 C 的 safe 分支:回调当帧当场在主线程执行,走同步直调那一支
图 3-32本机探针 C 的 safe 分支:回调当帧当场在主线程执行,走同步直调那一支@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

好了,面板里两个数字最要紧,一个是主线程 id 1,一个是回调线程 id 1,两者相同。它对应的机制就是 flush_if_pending() 之后直接调:没有跨线程,ASYNC_COND_PUSH 判为假,命令不入队,同步走完。再看异步那一支之前,先把口径交代清楚,--render-thread separate 是官方标注的实验性特性,启动时会打一行警告,原文措辞是「may lead to project crashes or instability」。这句警告在本机二进制里报的位置是 main/main.cpp:3516,那是本机构建(提交 7177493)的行号,与 master 快照已经漂移(快照里同一位置是空行),所以此处只作本机口径引用,不当快照行号用。探针 C 的 separate 分支同样跑在本机二进制 4.8.dev.custom_build.7177493c1(自定义构建)上,主线程 id 仍是 1,call 返回时回调尚未执行,探针记下的 _cb_thread 是 -1,随后回调由线程 id 7 执行。

本机探针 C 的 separate 分支:call 返回时回调尚未执行,随后由线程 id 7…
图 3-33本机探针 C 的 separate 分支:call 返回时回调尚未执行,随后由线程 id 7 执行(该特性官方标注为实验性)@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

这块面板里两组线程 id 分开了,调用侧是 1,执行侧是 7。它对应的机制是 command_queue.push,ASYNC_COND_PUSH 判为真,命令压进队列,由服务线程取走。这里有一条判别式必须钉住,判据不是帧号。两条分支下回调都可能落在同一帧,靠帧号去分是分不开的,能分的只有「call 返回时回调是否已经执行」加上「两次读到的线程 id 是否相同」。本机这一组两问都指向异步:返回时未执行,线程 id 从 1 变成 7。两分支各测出一次,这条「同一条命令、两种调用线程、两条路」的判断就有了本机落点。

7.4 界面上的线程与渲染读数

这一节要回答:这套运行期切换在编辑器界面上能不能看到。往界面上看一步,调试面板(Debugger / Monitors)把服务层的线程与渲染读数摆在界面上,读者不必翻源码,也能在面板上看到渲染侧的开销与线程相关项。官方教程序列把这条思路写下来了,服务层是地基,场景系统架在它上面并直接使用它,渲染服务还能整体绕开场景与节点系统。界面这一侧的证据与源码里的机关是同一件事的两面,前者给出现象,后者给出判据。

编辑器调试面板实拍:服务层的线程与渲染读数在界面上露出的地方,界面版本为 Godot 3.3
图 3-34编辑器调试面板实拍:服务层的线程与渲染读数在界面上露出的地方,界面版本为 Godot 3.3@ Wikimedia Commons · SakuraMiyazono(MIT,原图未改动)

言归正传,把这一章收一下:一个类、一枚布尔、一条判据,两个分支各自在本机复现过一次。下一章把镜头往外挪一层,看同一种句柄模式在图形设备层怎么被整套复用。

源码落点:实现层的五行与判据见 rendering_server_default.h,队列自身见 command_queue_mt.h,全局注册表与 RSG 别名见 rendering_server_globals.h,开关的落地见 rendering_server_default.cpp,两个创建点与实验性警告见 main.cpp,线程边界的官方口径见 RenderingServer 类参考;其中: 实现层只有 RenderingServerDefault(rendering_server_default.h:47)一个类,:80 自带 command_queue、:82 标出 server_thread、:85 是运行期布尔 create_thread、:117 的 ASYNC_COND_PUSH 就是判据本体,创建点分在 main.cpp:772 与 :3591 两处,rendering_server_default.cpp:241 把开关回写到 rendering_server_globals.h:51,行号一律按 master(2026-10-08 快照)。

八、通行证跨层统一:RD 也吃 RID

这一章把镜头抬一层:服务层那套句柄之外,图形设备层自己那一套账怎么记,和本篇讲的这套为什么是同一个思路。往下只走到「RD 也吃 RID」为止,图形设备层的完整抽象归后面各篇。

8.1 RD 侧同样是 RID_Owner

这一节要回答:图形设备层用不用同一种句柄模式,它自己的账本长什么样。读 servers/rendering/rendering_device.h:214–216,那一处是 RID_Owner<Buffer, true> uniform_buffer_owner; 一族声明,模板第二个参数 true 就是线程安全开关;:415 是 texture_owner,纹理另立一本;:1917 是 void free_rid,RD 的显式释放入口也收句柄(RID)。缓冲、纹理、帧缓冲、着色器、管线各有一本账,用的账本类型与核心层那套是同一个 RID_Owner。

rendering_device.h 第 214 到 216 与第 415 与第 1917 …
图 3-35rendering_device.h 第 214 到 216 与第 415 与第 1917 到 1918 行原文:设备层自己那一套账,同样是 RID_Owner@ godot master 快照(2026-10-08)原文截取 · MIT

把这块面板读给读者听,三处落点给出三件事:缓冲有一本账、纹理有一本账、释放也按句柄来。它和本篇前面的 RID_Alloc 是同一族机制,core/templates/rid_owner.h:517 的 class RID_Owner 同时被核心层与 RD 层引用,代际校验、分块记账、空闲栈这些做法整套照搬过去。要说清两层的分工,一句话就够,RenderingServer 的 RID 与 RenderingDevice 的 RID 是两本不同的账,各自管自己那一层,谁也不认领对方的句柄。

8.2 与 Jolt BodyID 的位预算对照

言归正传,这一节要回答:同样是「把过期信息编码进句柄」,Godot 与 Jolt 各给了多少位。先把位预算(bit budget)这个说法定清楚,它指一个句柄的固定宽度里,多少位给下标、多少位给代际或序号。Godot 这一侧是 8 字节,core/templates/rid.h:40 的 uint64_t _id = 0; 就是它的全部,位预算是低 32 位槽位、高 32 位代际(rid_owner.h:157–160)。Jolt 那一侧是 4 字节,位预算是低 23 位索引、高 8 位序号,这是本系列 T06《Jolt Physics 3D》第三篇已经交付并本机实测过的结论,此处直接引用。

同一族思路两种位预算:8 字节的 32 加 32 对 4 字节的 23 加 8
图 3-36同一族思路两种位预算:8 字节的 32 加 32 对 4 字节的 23 加 8@ 本系列绘制

这张对照把两组宽度并排放,8 字节对 4 字节,32 加 32 对 23 加 8。两边的共同思路一致,都把「过期」编码进句柄本身,避开裸指针的 ABA 问题。差别出在预算的宽紧:Godot 的 RID 要跨渲染、物理、文本等多个服务层复用,槽位预算必须留得宽;Jolt 的句柄只服务一件事,32 位就够用。判活的方式也不同,Godot 把回答下放到持有句柄的那个 owner(rid_owner.h:321 的 owns() 连代际一起比),Jolt 的句柄自带序号,取下标之后由 BodyInterface 校验。

源码落点:RD 侧的账本声明见 rendering_device.h,同族机制的定义见 rid_owner.h,句柄本体见 rid.h,图形设备层的官方口径见 RenderingDevice 类参考,服务层与 RID 的定位见 用服务器优化;其中: rendering_device.h:214–216 的 RID_Owner<Buffer, true>、:415 的 texture_owner 与 :1917 的 free_rid 说明 RD 侧整套复用 RID_Owner 模式,位预算对照为 8 字节(低 32 槽位、高 32 代际)对 4 字节(低 23 索引、高 8 序号),Jolt 一侧取自 T06 第三篇已交付的结论,行号一律按 master(2026-10-08 快照)。

九、收官:这套接口形状买到什么,代价是什么

这一章收口,先把两处被旧内容带偏的印象纠正过来,再看这套接口形状的取舍落在哪里,最后给出后面各篇各看哪一层的映射。

9.1 两处旧印象纠正

这一节要回答:关于服务层的创建入口与线程实现,有哪些流传的说法和现 master 对不上。把这两处旧印象摆在明面上。第一处在创建入口,不少二手内容写「引擎启动时调 RenderingServer::create_default() 装上默认实现」,现 master 里没有这个函数,入口是 RenderingServer::create()(rendering_server.cpp:51),它转调一个函数指针 create_func(:45),而默认实现是在 main.cpp:772 与 :3591 直接 memnew 出来的。第二处在类名,引擎在实验性的 --render-thread separate 下会打印 RenderingServerWrapMT: Starting render thread(rendering_server_default.cpp:278),可这个类在现 master 已经不存在,servers/server_wrap_mt.h 的 HTTP 状态实测为 404(2026-10-08 核),本篇覆盖的快照文件里只有这一条日志字符串、没有任何类定义。

本机 --verbose 输出原文:RenderingServerWrapMT 这个旧类名已…
图 3-37本机 --verbose 输出原文:RenderingServerWrapMT 这个旧类名已经不存在了@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

言归正传,这块面板给的是本机 --verbose 的输出,读数出自 4.8.dev.custom_build.7177493c1(2026-07-03 构建,自定义构建),只在本机口径下成立。这行字符串就是「存在单线程版与多线程版两个实现类」这个旧印象的源头,读到它就顺势推出两个类的结论,恰好被这行没跟着改的旧日志文案带偏。把两处合起来看,结论只有一句,create_default() 不存在,RenderingServerWrapMT 只是遗留文案。

9.2 后面各篇各看哪一层

这一节要回答:本篇停在哪一层,后面各篇各从哪一层往下拆。这里要交代的边界很窄,这张表只回答「看哪一层」,本篇不给出后面每一篇的标题。

这条链后面各段看谁:本篇停在服务层接口,后面各段各拆一层
图 3-38这条链后面各段看谁:本篇停在服务层接口,后面各段各拆一层@ 本系列绘制
篇 看哪一层 这一层在本篇这条链上的位置
第四篇 Server 层·剔除 RendererCanvasCull 决定画什么、按什么顺序
第五篇 Server 层·抽象 绘制命令的中间表示与命令家族
第六篇 Server 层·攒批 从画布项到一次 draw call 的攒批规则与后端演进
第七篇 Server 层·RD RenderingDevice 抽象与渲染依赖图
第八篇 Drivers 层 后端分叉与运行时选择
第九篇 Main 主循环与多线程命令队列,驱动整条链
第十篇 全链收官 这套分层分工的取舍

这张表把服务层接口这一层与后面各层接成一条链。本篇停在门面与实现层之间的那组 RID 型命令,第四篇往下拆剔除与排序,第五到第八篇顺着渲染抽象往下走,第九篇回到驱动整条链的主循环,第十篇收口。取舍可以分两面说:换来的是解耦与可排队,句柄不透明、命令可跨线程,服务层能整体绕开场景层;付出的是间接与边界,一次调用要穿门面、宏、两拍与队列,调用方还得自己盯住句柄的有效期。本篇的任务到这里完成,服务层的接口形状已经指到每一处源码落点。

源码落点:创建入口见 rendering_server.cpp 与 rendering_server.h,旧日志文案见 rendering_server_default.cpp,创建点见 main.cpp,剔除与排序的落点见 renderer_canvas_cull.cpp,服务层的定位见 RenderingServer 类参考;其中: RenderingServer::create()(rendering_server.cpp:51)是唯一创建入口、create_default() 在现 master 不存在,RenderingServerWrapMT 只剩 rendering_server_default.cpp:278 一行日志且 servers/server_wrap_mt.h 实测 404,行号一律按 master(2026-10-08 快照)。

资源清单

官方文档(CC BY 3.0 Unported,署名 “Juan Linietsky, Ariel Manzur and the Godot Engine community”,引用须标注改动):RenderingServer 类参考、RID 类参考、Object 类参考、RenderingDevice 类参考、用服务器优化、自己写一个服务、内部渲染架构、对象与类。文档许可见 godot-docs 仓库的 LICENSE。

官方源码(godot 仓库 master raw;MIT,行号按 2026-10-08 快照):rid.h、rid_owner.h、command_queue_mt.h、object_id.h、object.h、rendering_server.h、rendering_server.cpp、rendering_server_default.h、rendering_server_default.cpp、rendering_server_globals.h、server_wrap_mt_common.h、renderer_canvas_cull.cpp、rendering_device.h、main.cpp。

官方发布与设计文章:为什么 Godot 用服务器与 RID、核心重构进展报告 #2。许可与商标:引擎许可、官方美术与商标。Godot 名称与 Logo 属商标,配图与说明不得暗示官方背书。

官方素材(编辑器截图):Godot 截图类目、Godot 启动画面类目。

言归正传,相关链接(站内与竞品口径):第二篇 对象模型、第一篇 全景与边界、caimouse《Godot 源码分析》专栏、caimouse《RID 与 RID_Owner》、graypigen1990《RenderingServer 总结》。

本机一手产物:evidence/probe/ 下 A_rid_headless.txt(代际与悬空句柄)、B_leak_headless.txt(泄漏现场)、C1_threadsafe_headless.txt 与 C2_threadseparate_headless.txt(两条线程分支)、D_verbose_threadseparate.txt(--verbose 启动日志),另有 evidence/ep03_src_lines.txt(94 处落点,机器核对 0 FAIL)。本机读数用的引擎版本为 4.8.dev.custom_build.7177493c1(2026-07-03 构建,自定义构建),只在本机口径下成立,不跨机器外推。

下篇预告

下一篇把镜头移到画布剔除与排序,专讲 RendererCanvasCull 决定画什么、按什么顺序:第四篇(站内链接)。

With Best Wishes.