Godot 4 引擎架构 · 第 四 篇

画布剔除与排序:RendererCanvasCull 决定画什么、按什么顺序

2026-10-0814,610 字40 张配图本文由@溯光 出品,转载请注明出处。

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

题图:Godot 4.2 编辑器整窗界面
题图题图来自《Godot 4.2 编辑器整窗(西班牙语界面)》@ Wikimedia Commons(作者见 image-credits.json;MIT 许可,署名要求;原图未改动;Godot 名称与 Logo 是商标,本处不暗示官方背书)

切口:一次 2D 绘制里,「画什么」和「什么顺序」是两件事

这一章要把本篇的位置说清楚:第三篇我们走完了一条命令从门面到执行的全程,可那条命令只是一次绘制的内容。它有没有被画出来、在别人前面还是后面,是另外两个问题。

0.1 全系列的目录:这一条链有四层,本篇落在第三层

OK,先把整个系列按层摊开,大家好知道自己在哪一格。这套架构的主线是一次 2D 绘制的四层落点:从场景层的 CanvasItem(画布项)出发,经画布剔除(canvas culling),落到画布渲染抽象层,再经 RenderingDevice(渲染设备)变成驱动能吃的命令缓冲。

层 谁在做 这一层回答什么 本系列哪一篇
场景层 Node / SceneTree / CanvasItem 谁在什么时候下发命令 第二篇
服务层 RenderingServer 门面与其实现 命令怎么被接收、怎么被排队 第三篇
剔除与排序 RendererCanvasCull 画什么、按什么顺序 本篇(第四篇)
画布渲染抽象 RendererCanvasRender 一个绘制项里到底存了什么 第五篇
批处理与后端 攒批规则 + 四个后端分叉 一次绘制怎么变成一次 draw call 第六、八篇
设备层 RenderingDevice 命令列表怎么排成依赖图 第七篇
横切 主循环与线程、资源导入 逻辑与渲染怎么错位、图从哪来 第九、十篇

这套结构的原理很朴素,其中:先把「画什么」筛出来,再给筛出来的东西排位置,两者共用同一次遍历。

这张表也顺带交代了本篇的边界:批处理规则、后端分叉、RenderingDevice 的命令列表与依赖图、主循环与线程池的完整骨架、资源导入与显存路径,本篇都不涉及——它们分别是第六、八、七、九、十篇的题目。画布渲染抽象层的内部结构也不展开,本篇只走到它与剔除层共用同一个 Item 这一个接缝上。

0.2 两个问题的官方口径

官方文档把「顺序」讲得很直白,只是分散在三处。CanvasItem 类参考的开篇写着:

"Canvas items are drawn in tree order on their canvas layer. By default, children are on top of their parents, so a root CanvasItem will be drawn behind everything. This behavior can be changed on a per-item basis."

CanvasLayer(画布层)类参考把层级与 z 的优先关系写死了:

"The layer is a numeric index that defines the draw order. The default 2D scene renders with index 0, so a CanvasLayer with index -1 will be drawn below, and a CanvasLayer with index 1 will be drawn above. This order will hold regardless of the CanvasItem.z_index of the nodes within each layer."

CanvasItem.y_sort_enabled 的说明则把 y 排序的作用域限定了:

"If true, child nodes with the lowest Y position are drawn before those with a higher Y position. If false, Y-sorting is disabled. Y-sorting only affects children that inherit from CanvasItem. You can nest nodes with Y-sorting. Child Y-sorted nodes are sorted in the same space as the parent Y-sorted node."

三句合起来是用户口径的三层箱位:CanvasLayer.layer 管画布级、z_index 管项级、树序兜底。可它没回答我们真正关心的问题——这三层在实现上是怎么落成一次遍历的,以及「画什么」是在遍历的第几步被判掉的。这些问题要回源码。

0.3 两件事:一个递归、一个汇总

先来看看源码给的结构。整条链只有两个入口值得记:RendererCanvasCull::render_canvas()(剔除器对外的入口)与它内部的 _render_canvas_item_tree()(真正干活的那一层)。

一次 2D 绘制的两个问题:「画什么」由递归剔除循环回答、「什么顺序」由桶汇总循环回答,两者在同一次遍历的两端
图 4-1一次 2D 绘制的两个问题:「画什么」由递归剔除循环回答、「什么顺序」由桶汇总循环回答,两者在同一次遍历的两端@ 本系列绘制

「画什么」由递归回答:_cull_canvas_item()(一个递归函数)从画布根开始往下走,每一项先过几道判据,过不了的连同它的整棵子树一起出局。「什么顺序」由一次汇总回答:所有活下来的项被塞进一排「桶」里,遍历结束之后再从低到高把桶串成一条链,交给下游。说白了,前者是筛,后者是排,它们在同一次遍历的头尾两端,中间没有第三次遍历。

这也是本篇敢把「剔除与排序」放在一篇里的原因:它们在源码里本来就是同一件事的两半。

0.4 本篇要留下的三个可检验命题

好了,写到这里,本篇的骨架可以压成三句话,后面每一章都是在给其中一句找证据:

  1. 顺序是三层箱位,不是一次排序:CanvasLayer.layer → 项内序号 → z_index,三层依次生效;z 不是「比较大小后重排」,而是装箱。
  2. 剔除是五道闸门:其中四道一旦不过就是整棵子树出局,第五道只决定这一项自己进不进桶。
  3. 两个例外:y 排序会把子树拍平后按最终 y 重排;CanvasGroup(画布组)会把子项先挪进背缓冲,因此它的 alpha 与 clip_children 行为都跟普通父节点不同。

0.5 三层证据与本篇的口径

本文的每一个论断都标清它站在哪一层证据上,三层分开陈述,不混着说:

层 来源 本篇承担什么 不可用它做什么
官方文档 docs.godotengine.org 的类参考与教程 用户口径(谁在上谁在下) 不回答实现怎么做
官方源码原文 godotengine/godot master 快照 2026-10-08(MIT) 机制层:判据、结构、字段 不当作运行结果
本机探针实测 自建引擎 4.8.dev.custom_build.7177493c1(2026-07-03 提交) 可观测事实:像素读数、计数 不给跨机器、跨版本结论

三条纪律,后面每章都会用到:

  1. 行号属于提交,不属于版本号。本机 clone 是 2026-07-03 的提交,与 master 快照已漂移,且漂移量逐文件不同——所以本文不做算术平移:引用快照行号就标快照日,引用本机读数就带那个版本串,两者绝不互相换算。
  2. 本机读数一律带版本串与「自定义构建」四个字。这不是客套:同一个 z_index 的边界行为、CanvasGroup 的 alpha 语义,都可能在版本之间变。
  3. 探针必须带窗口跑。--headless 用的是 dummy 渲染器(空实现),get_viewport().get_texture() 读回来全是黑的——本篇的判定全部基于像素读回,headless 下会整体失真。这一点在第二篇的探针里已经用 draw call 恒为 0 的现象验证过。

另外交代一句为什么本篇的本机探针这么多:剔除与排序是看不见的一层,它不像界面或实拍那样有现成的外部图可用(阶段 2 的检索实测里,与本题域相关的关键词组有六组是零命中)。所以本篇的证据重心只能是源码原文加上本机对照。

0.6 怎么读这篇

给三条阅读建议,省些来回翻的力气:

  • 想先弄懂「顺序」:读第一到第四章(三层箱位),第五章是这条主路上唯一的例外。
  • 想先排查问题:直接跳到第六章的 §6.6「排查手册」,那份表是本章倒过来用的版本;再回来看判据的来处。
  • 想弄清一个具体结论有没有依据:每章末都有「本章出处」一行,列出该章引用的源码文件与官方页面;本机读数一律带版本串。

本章出处:三层落点链的分层依据见官方 2D 坐标与变换说明;场景层那一跳的完整走法在第二篇,服务层那一跳在第三篇。

一、第一层箱位:CanvasLayer.layer 与它的 64 位打包

这一章回答:CanvasLayer.layer 为什么不用一个整数直接比大小,而要打包成一个 64 位数;以及视口的画布表为什么自己存的时候是无序的。

1.1 排序键:一个字段扛下 layer 与 sublayer

先来看看排序键本身。RendererCanvasCull 的兄弟模块 RenderererViewport(渲染视口)里有个小结构,它是整条顺序链的第一层:它把「这块画布排第几层」压成一个可直接比大小的整数,后面所有跨画布的先后关系都从它开始。换句话说,谁在谁前面,第一个判据落在这个结构上,而不是落在任何节点的属性上。

renderer_viewport.h:124–142 原文:CanvasKey::stacking 的打包…
图 4-2renderer_viewport.h 第 124 到 142 行原文:CanvasKey 的字段、比较运算与构造打包式@ godot master 快照(2026-10-08)原文截取 · MIT

它只有一个用于排序的字段 stacking,另有一个 canvas 句柄用于打平手时的比较:

// renderer_viewport.h:124–142(master 快照 2026-10-08)
struct CanvasKey {
    int64_t stacking;
    RID canvas;
    bool operator<(const CanvasKey &p_canvas) const {
        if (stacking == p_canvas.stacking) {
            return canvas < p_canvas.canvas;
        }
        return stacking < p_canvas.stacking;
    }
    CanvasKey() {
        stacking = 0;
    }
    CanvasKey(const RID &p_canvas, int p_layer, int p_sublayer) {
        canvas = p_canvas;
        int64_t sign = p_layer < 0 ? -1 : 1;
        stacking = sign * (((int64_t)Math::abs(p_layer)) << 32) + p_sublayer;
    }
    int get_layer() const { return stacking >> 32; }
};

其中:layer 被搬到高 32 位,sublayer(子层)留在低位,两者合成一个有序的 64 位数。

1.2 为什么负 layer 要单独取符号

先来看看最容易被写错的那一行:sign * (abs(layer) << 32) + sublayer。如果偷懒写成「layer 左移 32 位再或上 sublayer」,负数的补码会把高 32 位全填成 1,比较结果立刻乱套——layer = -1 会被排到 layer = 0 的后面。

为什么负 layer 不能直接按「layer 左移 32 位再或上 sublayer」处理:sign × (abs…
图 4-3为什么负 layer 不能直接按「layer 左移 32 位再或上 sublayer」处理:sign × (abs(layer) << 32) + sublayer 的位图与符号位处理@ 本系列绘制

官方文档说「-1 的层画在 0 层之下、1 层画在其上」,这句用户口径能成立,靠的就是这行显式的符号处理。这是本篇第一处「文档说得简单、实现并不简单」的地方。

1.3 视口自己有表,但那张表是无序的

下面进入调用侧。视口里确实存了一张画布表,但它是个 HashMap:

// renderer_viewport.h:151–155(master 快照 2026-10-08)
    Transform2D global_transform;

    HashMap<RID, CanvasData> canvas_map;

    RenderingServerTypes::RenderInfo render_info;

哈希表没有顺序可言。顺序是在绘制那一刻才建立的:RenderererViewport::draw_viewports() 里另建一棵按 CanvasKey 排序的红黑树(RBMap),把画布逐个塞进去,再顺序遍历。

renderer_viewport.cpp:416/545/691–719 原文:视口自存的是 HashMap…
图 4-4renderer_viewport.cpp 第 414 到 418 行原文:绘制时另建的 RBMap,键是 CanvasKey@ godot master 快照(2026-10-08)原文截取 · MIT

按 key 顺序遍历这棵树,就得到了「从低层到高层」的画布顺序;每遍历到一个画布,就把它的变换、光照、裁剪矩形连同视口的剔除掩码一起交给剔除器。也就是说,第一层箱位在进入剔除器之前就已经定好了。

1.4 本机对照:layer 压 z

言归正传,口说无凭。本篇的探针工程放了两块互相重叠的方块,一块放进 layer = 1 的画布层并给它 z_index = -100,另一块留在默认层并给它 z_index = +100:

本机探针 A4:layer=1 且 z_index=-100 的项,压在默认层 z_index=+100…
图 4-5本机探针 A4:layer=1 且 z_index=-100 的项,压在默认层 z_index=+100 的项之上——layer 压 z@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

中心那一点最终是 (1.0, 0.0, 0.0, 1.0),也就是 layer = 1 的那块红。z 差了 200,也没能翻盘——这正是官方那句 "regardless of" 的实测版。注意:本机引擎是自建版本 4.8.dev.custom_build.7177493c1(2026-07-03 提交),下面的本机读数都带这个版本串,不给跨机器结论。

1.5 打包式的预算:31 位给 layer,32 位给 sublayer

依然是惯例,按上面那行式子拆开算一遍,stacking 这个 64 位整数是这样分配的:

位段 装什么 依据
最高 1 位 符号(由 layer 的正负决定) int64_t sign = p_layer < 0 ? -1 : 1;
高 32 位里的低 31 位 abs(layer) ((int64_t)Math::abs(p_layer)) << 32
低 32 位 sublayer + p_sublayer

也就是说:layer 的可表达范围被限制在 31 位以内,sublayer 独占低 32 位。打平手时(stacking 完全相等)才比 canvas 这个 RID——而 RID 的比较是拿句柄本体比大小,也就是分配顺序。⇒ 同一层同一子层下,先创建的画布排在前面。

这里有个容易误解的地方:sublayer 不是一个「微调优先级」的浮点数,它是个整数,且只在同一 layer 内参与比较。跨 layer 时高 32 位已经分出胜负,低位是什么都不影响。

本章出处:renderer_viewport.h 与 renderer_viewport.cpp(master 快照 2026-10-08,MIT)。本机读数来自探针 A4(evidence/probe/E_order_visibility_group_ysort.txt)。

二、第二层箱位:树序 → draw_index

这一章回答一个很容易被当成小事的问题:层内的先后到底由谁给号。答案不止一个,而是三套来源。不过先来看看这些属性在编辑器里长什么样——它们是这一章所有结论的入口。

编辑器界面:场景树与检查器并列,排序相关的字段都落在检查器那一栏
图 4-6Godot 3.1 编辑器界面:左侧场景树、右侧检查器,本篇要讲的排序字段就落在检查器那一栏@ Wikimedia Commons(作者见 image-credits.json;CC BY-SA 4.0;原图未改动;Godot 名称与 Logo 为商标,本处不暗示官方背书)

排序与可见性相关的属性大多挤在检查器的同一栏里,界面上它们平铺在一起,看不出谁压谁:z_index 与 z_as_relative 决定这一项最终进哪个桶,y_sort_enabled 是第五章的主角,show_behind_parent 决定它画在父项之前还是之后。优先关系只能从源码里读。注意:这张截图的界面是英语、版本是 Godot 3.1,与本文引用的 master 版本不同——它只用来指出「这些字段在编辑器里的位置」,字段名与取值一律以正文的源码口径为准。

2.1 序号不是树序,是从树序算出来的一个整数

CanvasItem 的树序(谁在父节点里排第几)不会直接被剔除器读。场景层会把树序转成一个整数下发;剔除器那边收下的就是 Item::index 这一个 int。它按这个值给子项排序:

// renderer_canvas_cull.cpp:315–318(master 快照 2026-10-08)
    if (ci->children_order_dirty) {
        ci->child_items.sort_custom<ItemIndexSort>();
        ci->children_order_dirty = false;
    }

而 ItemIndexSort(renderer_canvas_cull.h:111–115)只有一行:return p_left->index < p_right->index;。

2.2 三套来源:谁给谁取号

先来看看这个接口。canvas_item_set_draw_index() 这个接口名看着平常,但它在场景层有两个调用点,而取号对象取决于节点处在什么位置:

canvas_item.cpp:253/255/533 原文:绘制序号的三套来源(CanvasLayer 的计数…
图 4-7canvas_item.cpp 第 247 到 257 与第 522 到 535 行原文:两处取号逻辑,取号对象各不相同@ godot master 快照(2026-10-08)原文截取 · MIT
// scene/main/canvas_item.cpp:247–257(master 快照 2026-10-08)
void CanvasItem::_top_level_raise_self() {
    if (!is_inside_tree()) {
        return;
    }

    if (canvas_layer) {
        RenderingServer::get_singleton()->canvas_item_set_draw_index(canvas_item, canvas_layer->get_sort_index());
    } else {
        RenderingServer::get_singleton()->canvas_item_set_draw_index(canvas_item, get_viewport()->gui_get_canvas_sort_index());
    }
}
谁在取号 取的是什么 落点
父级是 CanvasLayer 的画布项 该 CanvasLayer 自己的一个计数器 canvas_item.cpp:253
其余画布项(这一支) 视口的 GUI 绘制计数器 canvas_item.cpp:255
需要改序的普通画布项 自己在父节点里的序号 canvas_item.cpp:533

第三支在另一个函数里,它顺便交代了一个容易混的邻居——canvas_group 这个名字看着像「画布组」,实际是组名字符串:

// scene/main/canvas_item.cpp:522–535(master 快照 2026-10-08)
void CanvasItem::update_draw_order() {
    ERR_MAIN_THREAD_GUARD;

    if (!is_inside_tree()) {
        return;
    }

    if (canvas_group != StringName()) {
        get_tree()->call_group_flags(SceneTree::GROUP_CALL_UNIQUE | SceneTree::GROUP_CALL_DEFERRED, canvas_group, "_top_level_raise_self");
    } else {
        ERR_FAIL_NULL_MSG(get_parent_item(), "Moved child is in incorrect state (no canvas group, no canvas item parent).");
        RenderingServer::get_singleton()->canvas_item_set_draw_index(canvas_item, get_index());
    }
}

也就是说:带画布组名的节点改序时,走的是「延迟调用整组的 _top_level_raise_self」,而不是自己设一个序号。注意:这里的 canvas_group 与第八篇要讲的 CanvasGroup 节点不是一回事:前者是场景层的一个组名(用来把一组顶层节点一起抬序),后者是服务层那个会动用背缓冲的隔离机制。

2.3 layer 与层内序号是两个数

OK,这里最容易搞混:CanvasLayer.layer 是层与层之间的顺序,而层内部的先后用的是另一个数,它的来源是一个计数器:

// scene/main/canvas_layer.cpp:264–269(master 快照 2026-10-08)
void CanvasLayer::reset_sort_index() {
    sort_index = 0;
}

int CanvasLayer::get_sort_index() {
    return sort_index++;
}

注意 return sort_index++; —— 这是个读一次、自增一次的计数器。视口侧那个 GUI 计数器写法一模一样:

// scene/main/viewport.cpp:3811–3813(master 快照 2026-10-08)
int Viewport::gui_get_canvas_sort_index() {
    return gui.canvas_sort_index++;
}
canvas_layer.cpp:264–269 与 viewport.cpp:3806–3813 原文:两…
图 4-8canvas_layer.cpp 第 264 到 269 行与 viewport.cpp 第 3811 到 3813 行原文:两处「读一次自增一次」的发号器@ godot master 快照(2026-10-08)原文截取 · MIT

为什么这样写? 因为「谁先来谁拿小号」这件事,只能在进画布的顺序里确定,不能在事后补算。于是序号不是查出来的,而是发号发出来的。

三套序号来源的对照:谁给谁取号、在什么时机取、取出来的值会不会变
图 4-9三套序号来源的对照:谁给谁取号、在什么时机取、取出来的值会不会变@ 本系列绘制

注意:这里有一处旧印象要纠正:包括我在内的不少人会把「层内序号」直接等同于 layer,或者等同于节点在场景面板里的位置。按源码看,层内序号是发号器发出来的——CanvasLayer 下的项拿的是该层计数器的值,其余项拿的是视口 GUI 计数器的值,需要改序的普通节点才用自己的树序号。三套来源混在一起,才构成了「树序」这层箱位。

2.4 序号只置脏,不立刻重排

还有一个设计细节值得记:改序号、挂载节点这些动作不会当场触发排序,它们只是把标记置脏。

// renderer_canvas_cull.cpp:1966 与 :1972(master 快照 2026-10-08)
    canvas_item_parent->children_order_dirty = true;
    ...
    canvas->children_order_dirty = true;

排序发生在下一次剔除走到这一项的时候(cull.cpp:315–318),画布级同理(cull.cpp:499–502)。说白了,顺序是「用到才算」的,这也解释了后文那个桶模型为什么能这么省。

2.5 本机对照:同 z 时后加者在上

好了,把两块重叠的方块放进同一层、同为 z_index = 0,先加绿、后加红:

本机探针 A1:同 z 时后加者在上,树序是第三层箱位
图 4-10本机探针 A1:同 z 时后加者在上,树序是第三层箱位@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

中心点是 (0.0, 1.0, 0.0, 1.0)——后加的绿在上。这与官方那句「children are on top of their parents... the topmost nodes in the scene panel are drawn on behind lower ones」对得上:场景面板里越靠上、画得越靠后(这里容易反直觉,所以官方在教程里特意加了提醒)。

再补一条本机读数:给先加的那块 z_index = 1、后加的那块保持 0,中心点立刻变成先加的那一块。⇒ z 压树序成立,这是第三层箱位与第二层箱位的先后关系,下一章从实现上看它为什么必然如此。

2.6 一张全量清单:影响顺序与可见性的属性都在这儿

OK,写到这里,可以把散在前两章的属性归齐了。下面这张表按功能分了三类——注意这三类的边界,它就是本篇的目录:

第一类 · 决定顺序的(谁先画)

  • CanvasLayer.layer:层与层之间,压过一切项内属性。
  • CanvasLayer 的层内发号器(get_sort_index()):只决定同层内的先后。
  • Viewport 的 GUI 发号器(gui_get_canvas_sort_index()):第二、三套序号来源里的另一套。
  • CanvasItem.z_index:进入 z 桶的偏移量。
  • CanvasItem.z_as_relative:决定 z 是累加还是取绝对值。
  • CanvasItem.y_sort_enabled:把子树拍平后按最终 y 重排。
  • CanvasItem.show_behind_parent:把这一项挪到父项之前画。
  • CanvasItem.top_level:断开父链变换。
  • CanvasItem.canvas_group:一个组名字符串,带它的节点改序时走延迟抬序。

第二类 · 决定去留的(到底画不画)

  • CanvasItem.visible:最外层开关。
  • CanvasItem.visibility_layer 与 Viewport.canvas_cull_mask:按位与决定整条链的可见性。
  • CanvasItem.modulate / self_modulate:累加后小于 0.007 就不画。
  • CanvasItem.clip_children:裁剪区域退化到 0 就不画。

第三类 · 改变「怎么合成」的(画出来之后算什么)

  • CanvasItem.clip_children(在组外时)与 CanvasGroup:都要动背缓冲,二者互斥。
  • CanvasGroup.fit_margin / clear_margin / use_mipmaps:决定背缓冲多大、要不要 mipmap。
  • CanvasItem.material / use_parent_material:决定这一项能不能和邻居合并(下一篇的主题)。

二次归类:这三类恰好对应三个不同的问题——「排」问的是遍历顺序,「筛」问的是判据,「合」问的是中间表示。本篇只讲前两类,第三类里唯一要讲的是它与前两类的冲突点:CanvasGroup 一旦动用背缓冲,clip_children 就失效(第八章)。

这张清单还有个实际用法:排查「我的节点不见了」,按第二类从上往下查;排查「我的节点压错人了」,按第一类从上往下查。顺序是从外到内、从大到小。

本章出处:canvas_item.cpp、canvas_item.h、canvas_layer.cpp、viewport.cpp(均为 master 快照 2026-10-08)。本机读数来自探针 A1 与 A5。

三、第三层箱位:z_index 与 8193 个桶

下面进入顺序链的最后一层。这一章要拆掉一个流传很广的说法:z_index 不是「排序依据」,它是装箱依据。

3.1 z 有硬边界,不是想给多大给多大

下面进入正题。先把值域钉住——CanvasItem 的 z_index 上下限写在 rendering_server_enums.h 的枚举里。这两个常量不只是「合法范围」的声明:它们直接决定了后面那排桶一共有多少格,也决定了越界时程序是夹一下还是明确拒绝。先看边界、再看结构,这个顺序不能反过来读。

// servers/rendering/rendering_server_enums.h:37–42(master 快照 2026-10-08)
enum {
    NO_INDEX_ARRAY = -1,
    ARRAY_WEIGHTS_SIZE = 4,
    CANVAS_ITEM_Z_MIN = -4096,
    CANVAS_ITEM_Z_MAX = 4096,
rendering_server_enums.h:40–41 原文:CANVAS_ITEM_Z_MIN = -…
图 4-11rendering_server_enums.h 第 37 到 42 行原文:z 的硬边界 CANVAS_ITEM_Z_MIN 与 CANVAS_ITEM_Z_MAX@ godot master 快照(2026-10-08)原文截取 · MIT

越界会被直接拒绝,不是夹一下就算了事的:

// renderer_canvas_cull.cpp:1883–1885(master 快照 2026-10-08)
void RendererCanvasCull::canvas_item_set_z_index(RID p_item, int p_z) {
    ERR_FAIL_COND(p_z < RSE::CANVAS_ITEM_Z_MIN || p_z > RSE::CANVAS_ITEM_Z_MAX);

为什么要有边界? 因为 z 要变成一个数组下标。有了 -4096 到 4096,桶的个数就是固定的:

// renderer_canvas_cull.h:216–219(master 快照 2026-10-08)
    static constexpr int z_range = RSE::CANVAS_ITEM_Z_MAX - RSE::CANVAS_ITEM_Z_MIN + 1;

    RendererCanvasRender::Item **z_list;
    RendererCanvasRender::Item **z_last_list;

z_range 等于 4096 - (-4096) + 1,也就是 8193。

3.2 桶是「数组 + 链表」,而且只分配一次

renderer_canvas_cull.h:216–219 原文:z_range 常量与 z_list…
图 4-12renderer_canvas_cull.h 第 216 到 219 行原文:z_range 常量与两个桶数组的声明@ godot master 快照(2026-10-08)原文截取 · MIT

两个数组的长度都是 z_range,而且它们在构造期就分配好,不是每帧申请。这一点值得单独说:分配只做一次,意味着每帧的固定开销里只剩「清零」这一项,没有申请与释放;而清零是可以一次 memset 做完的连续操作,比逐个节点重建要稳定得多。

// renderer_canvas_cull.cpp:2828–2831(master 快照 2026-10-08)
RendererCanvasCull::RendererCanvasCull() {
    ...
    z_list = (RendererCanvasRender::Item **)memalloc(z_range * sizeof(RendererCanvasRender::Item *));
    z_last_list = (RendererCanvasRender::Item **)memalloc(z_range * sizeof(RendererCanvasRender::Item *));

按 64 位机器算,两个数组一共 8193 × 8 × 2 ≈ 128 KiB。每帧把这 128 KiB 清零,就是从零重建一次顺序表。

3.3 一次遍历:先装箱,再串链

好了,进入本篇最核心的一段代码。_render_canvas_item_tree() 的骨架只有三件事:

// renderer_canvas_cull.cpp:71–108(节选;master 快照 2026-10-08)
    RENDER_TIMESTAMP("Cull CanvasItem Tree");
    ...
    memset(z_list, 0, z_range * sizeof(RendererCanvasRender::Item *));
    memset(z_last_list, 0, z_range * sizeof(RendererCanvasRender::Item *));

    for (int i = 0; i < p_child_item_count; i++) {
        _cull_canvas_item(p_child_items[i].item, p_transform, p_clip_rect, Color(1, 1, 1, 1), 0, z_list, z_last_list, nullptr, nullptr, false, p_canvas_cull_mask, Point2(), 1, nullptr);
    }

    RendererCanvasRender::Item *list = nullptr;
    RendererCanvasRender::Item *list_end = nullptr;

    for (int i = 0; i < z_range; i++) {
        if (!z_list[i]) {
            continue;
        }
        if (!list) {
            list = z_list[i];
            list_end = z_last_list[i];
        } else {
            list_end->next = z_list[i];
            list_end = z_last_list[i];
        }
    }
renderer_canvas_cull.cpp:79–100 原文:每帧两次 memset 清桶,再从低到…
图 4-13renderer_canvas_cull.cpp 第 79 到 100 行原文:清空 8193 个桶,再把非空桶从低到高串成一条链@ godot master 快照(2026-10-08)原文截取 · MIT

三步就位:清桶 → 递归装箱 → 从低到高串链。第二步把每一项按它的绝对 z 放进对应的桶,第三步把 8193 个桶按 z 从小到大接成一条单链表。所以「z 小的画在前面」不是比较出来的,是桶的下标顺序天然给出的。

那每一项是怎么进桶的?往回看 _attach_canvas_item_for_draw():

// renderer_canvas_cull.cpp:274–287(master 快照 2026-10-08)
            int zidx = p_z - RSE::CANVAS_ITEM_Z_MIN;

            if (r_z_last_list[zidx]) {
                r_z_last_list[zidx]->next = ci;
                r_z_last_list[zidx] = ci;

            } else {
                r_z_list[zidx] = ci;
                r_z_last_list[zidx] = ci;
            }

            ci->z_final = p_z;

            ci->next = nullptr;

入桶下标 = 绝对 z − CANVAS_ITEM_Z_MIN。桶内是一条单链表,z_last_list 存尾指针,所以追加是 O(1):往尾巴上一挂就行。

renderer_canvas_cull.cpp:274–285 原文:桶内 O(1) 追加与 z_final…
图 4-14renderer_canvas_cull.cpp 第 274 到 285 行原文:桶内尾指针追加与 z_final 的落地@ godot master 快照(2026-10-08)原文截取 · MIT

3.4 桶模型的完整样子

8193 个桶的模型:入桶下标等于绝对 z 减 CANVAS_ITEM_Z_MIN,桶内是单链表、尾指针保证 O…
图 4-158193 个桶的模型:入桶下标等于绝对 z 减 CANVAS_ITEM_Z_MIN,桶内是单链表、尾指针保证 O(1) 追加@ 本系列绘制

好了,这个模型的代价与收益可以算清:

项 值
桶数 8193(z_range)
每帧清零 ≈128 KiB(两个指针数组:8193 × 8 × 2)
每项入桶 O(1)(尾指针追加)
汇总一次完成 O(8193) 扫描 + 每桶 O(1) 接链
排序比较次数 0(没有任何比较)

结论落到量化上:这篇的「排序」实际做的是装箱。全局排序要做 O(n log n) 次比较,而桶模型把顺序外包给了下标——顺序信息被事先编码进数组位置,运行时只剩加法与指针接续。这也解释了为什么 z 必须有硬边界:没有边界就没有下标。

3.5 一处容易误读的地方:z 只在必要时才累加

z_index 默认是相对父项的(z_as_relative = true),所以绝对 z 是累加并夹紧出来的:

// renderer_canvas_cull.cpp:431–436(master 快照 2026-10-08)
    int parent_z = p_z;
    if (ci->z_relative) {
        p_z = CLAMP(p_z + ci->z_index, RSE::CANVAS_ITEM_Z_MIN, RSE::CANVAS_ITEM_Z_MAX);
    } else {
        p_z = ci->z_index;
    }

注意这里夹的是累加结果,不是 z_index 本身。⇒ 一个深层节点即使自己只写了 z_index = 3,也可能因为父链累加而顶到 4096;反过来,CLAMP 之后桶下标永远落在 [0, 8192] 里,越界在那里是数学上不可能的。

本机探针 A5:两个 z_index=999 的项落进同一个桶,桶内保持插入序(后加者在上)
图 4-16本机探针 A5:两个 z_index=999 的项落进同一个桶,桶内保持插入序(后加者在上)@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

本机读数给了这条模型一个侧面证据:两个 z_index = 999 的重叠方块落在同一个桶里,结果是后加者在上——桶内没有再做任何排序,谁先被挂上谁就在前面。

另补一条本机读数:把父项设成 z_index = -5、子项自己设 z_index = 10,最终压在 z_index = 0 的对照块之上。-5 + 10 = 5 > 0,与上面那段 CLAMP 的累加式对得上。

3.6 三种「排序」的横向对比

OK,把桶模型和它的两个常见替代摆在一起,用一个统一维度表来看,才看得出它到底买到了什么:

维度 桶装箱(本篇实现) 全局比较排序 每帧重建整棵有序树
每帧比较次数 0 O(n log n) O(n log n)
每帧固定开销 ≈128 KiB 清零 无 无
每项入列代价 O(1)(尾指针追加) — 取决于树结构
结构变化代价 只置脏,下次用到才重排 每帧都付 每帧都付
可表达的 z 范围 受限于 8193 个桶 不受限 不受限
顺序信息的存放处 数组下标 比较函数 树结构

评价标准说明:这里的「代价」只看每帧的确定性开销与结构变化时的增量开销两项——因为剔除与排序是在渲染流程里每帧都要跑的东西,短突发的高开销比稳定的低开销更难接受。

对比的结论落在两句话上:顺序信息被事先编码进数组下标,运行时只剩加法与指针接续;代价是 z 必须有硬边界。为了避开比较排序而接受一个 8193 格的数组,这个取舍只有在「每帧都要重排」的前提下才划算——而 2D 场景恰好就是每帧都要重排。

本章出处:rendering_server_enums.h、renderer_canvas_cull.h、renderer_canvas_cull.cpp(均为 master 快照 2026-10-08,MIT)。本机读数来自探针 A5。

四、同一个桶里谁先谁后

这一章短,但它解释了「为什么每帧清空 128 KiB 也不心疼」。

4.1 桶内保持插入序,因为插入序就是正确顺序

说白了,在同一层、同一个 z 上,谁先画谁后画?答案在遍历里:子项是按序号从小到大被访问的,而访问到就往后追加,所以桶内链表天然按序号有序。这意味着「同一桶内再排一次序」是多余的——顺序信息在被访问的那一刻就已经确定,追加这个动作本身就在维护它。

// renderer_canvas_cull.h:111–115 与 :148–153(master 快照 2026-10-08)
    struct ItemIndexSort {
        _FORCE_INLINE_ bool operator()(const Item *p_left, const Item *p_right) const {
            return p_left->index < p_right->index;
        }
    };
    ...
        struct ChildItem {
            Item *item = nullptr;
            bool operator<(const ChildItem &p_item) const {
                return item->index < p_item.item->index;
            }
        };
renderer_canvas_cull.h:111–115 / 148–153 原文:ItemIndexSo…
图 4-17renderer_canvas_cull.h 第 111 到 115 与第 148 到 153 行原文:两个「按 index 排」的比较器@ godot master 快照(2026-10-08)原文截取 · MIT

一个给 Vector 用(sort_custom),一个给画布的子项数组用(直接 sort())。两者比较的都是同一个字段:index。

4.2 代价落在「只在脏的时候排」

// renderer_canvas_cull.cpp:315–318 与 :499–502(master 快照 2026-10-08)
    if (ci->children_order_dirty) {
        ci->child_items.sort_custom<ItemIndexSort>();
        ci->children_order_dirty = false;
    }
    ...
    if (p_canvas->children_order_dirty) {
        p_canvas->child_items.sort();
        p_canvas->children_order_dirty = false;
    }
桶内链表:为什么「不重排」也稳定,以及脏标记让排序不必每帧做
图 4-18桶内链表:为什么「不重排」也稳定,以及脏标记让排序不必每帧做@ 本系列绘制

言归正传,回到那个「128 KiB 清零便不便宜」的问题。清零是线性的(8193 次),排序是只在结构变化时才做的(children_order_dirty),入桶是 O(1)——三者叠起来,才是「顺序可以每帧重建却不贵」的完整解释。如果每帧都做一次全局比较排序,这套设计立刻站不住。

4.3 一次结构变化的完整回路

OK,把「改一个属性」到「画面顺序变化」的整条回路走一遍,前面几段代码就串起来了。假设我们给一个已经进树的节点设 z_index = 3:

  1. 场景层:CanvasItem::set_z_index() 里把值存下,然后只调一句 canvas_item_set_z_index(canvas_item, z_index)(canvas_item.cpp:757–761)。
  2. 服务层门面:这条命令经宏生成的转发(FUNCRIDSPLIT(canvas_item) 把这一段的实现层指定为 RendererCanvasCull,rendering_server_default.h:1016),落到剔除器。
  3. 剔除器:canvas_item_set_z_index() 先做越界检查(cull.cpp:1884),再写字段。注意它没有触发任何排序——z 只影响入桶的下标,不影响子项次序。
  4. 下一帧剔除:这一项按新 z 落进另一个桶;汇总时桶的顺序自然给出新位置。

如果改的是顺序类属性(比如插入了兄弟节点),回路就多一步:

  1. canvas_item_set_parent()(cull.cpp:576 起)把子项挂进新父项,并置脏(cull.cpp:1966 / 1972)。
  2. 下一帧 _cull_canvas_item() 走到这一项,发现 children_order_dirty,做一次 sort_custom<ItemIndexSort>()(cull.cpp:315–318)。
  3. 排序之后子项按 index 升序被访问,于是桶内的追加顺序随之改变。

这里的原理与第三章是同一处,其中:桶内先后不需要比较,只需要保证追加顺序正确,而追加顺序由序号排序保证。

⇒ 两条回路的差别值得记:改 z 不需要排序,改结构需要一次排序,而那次排序只发生一次(置脏后立刻清脏)。这就是「脏标记」在这里的全部作用——把排序从每帧摊到「真的变了」的那一帧。

本章出处:脏标记与比较器同见 renderer_canvas_cull.h;转发链的宏展开另见 server_wrap_mt_common.h(master 快照 2026-10-08)。

五、y 排序:把子树拍平,再按最终 y 排

前面四章讲的是「z 桶」这条主路。这一章讲例外一:y_sort_enabled 会绕过主路的一半。

5.1 y 排序比较的不是本地 y,而是最终位置的 y

这一节先看判据本身。y 排序的比较器只有几行,但它比的是哪一个 y,直接决定排序结果对不对:比本地坐标还是比最终位置,在父链带着变换的时候会给出完全不同的先后,而且错得很安静。

// renderer_canvas_cull.h:117–127(master 快照 2026-10-08)
    struct ItemYSort {
        _FORCE_INLINE_ bool operator()(const Item *p_left, const Item *p_right) const {
            const real_t left_y = p_left->ysort_xform.columns[2].y;
            const real_t right_y = p_right->ysort_xform.columns[2].y;
            if (Math::is_equal_approx(left_y, right_y)) {
                return p_left->ysort_index < p_right->ysort_index;
            }

            return left_y < right_y;
        }
    };
renderer_canvas_cull.h:117–127 原文:ItemYSort 按最终位置的 y 比…
图 4-19renderer_canvas_cull.h 第 117 到 127 行原文:ItemYSort 的判据是 ysort_xform 第三列的 y,相等时按 ysort_index 兜底@ godot master 快照(2026-10-08)原文截取 · MIT

ysort_xform.columns[2].y 是这项在世界里的最终 y。取最终位置而不是本地坐标,是因为父链的变换会把局部位置挪走——官方那句 "Child Y-sorted nodes are sorted in the same space as the parent Y-sorted node" 说的就是这个口径。

第二条判据 ysort_index 是稳定排序的兜底:y 完全相等时,按收集顺序决定,不引入随机性。

5.2 为什么必须先拍平

说白了,y 排序不能就地两两交换,因为它要跨层级比较。实现的做法是:把 y-sorted 这颗子树整体摊成一维数组,排完序之后再逐个送进剔除。

// renderer_canvas_cull.cpp:440–460(节选;master 快照 2026-10-08)
            if (ci->ysort_children_count == -1) {
                ci->ysort_children_count = _count_ysort_children(ci);
            }

            child_item_count = ci->ysort_children_count + 1;
            child_items = (Item **)alloca(child_item_count * sizeof(Item *));

            ci->ysort_xform = Transform2D();
            ci->ysort_modulate = Color(...);
            ci->ysort_index = 0;
            ci->ysort_parent_abs_z_index = parent_z;
            child_items[0] = ci;
            int i = 1;
            _collect_ysort_children(ci, p_material_owner, Color(1, 1, 1, 1), child_items, i, child_item_count, p_z, p_canvas_cull_mask);

            SortArray<Item *, ItemYSort> sorter;
            sorter.sort(child_items, child_item_count);
renderer_canvas_cull.cpp:444–456 原文:alloca 出临时数组、_col…
图 4-20renderer_canvas_cull.cpp 第 444 到 456 行原文:在栈上开临时数组、拍平收集、再排序@ godot master 快照(2026-10-08)原文截取 · MIT

三处细节值得停下来看一眼:

  1. 临时数组走的是 alloca(栈),不是堆——一次用量与子树规模成正比,用完随函数返回自动回收。
  2. 拍平前要数一遍:_count_ysort_children() 递归数出可见子孙的数量,结果缓存在 ysort_children_count 上,-1 表示「还没算」。
  3. 每个成员都要带上「父的绝对 z」:拍平之后它们会被逐个送回剔除,ysort_parent_abs_z_index 就是那时候用的。
拍平:y-sorted 子树被展成一维数组,每个成员再带着「父的绝对 z」回到剔除
图 4-21拍平:y-sorted 子树被展成一维数组,每个成员再带着「父的绝对 z」回到剔除@ 本系列绘制

5.3 缓存怎么失效

ysort_children_count 是缓存,于是就有失效问题。失效的做法是沿父链往上清,只清到最近的 y-sort 祖先——再往上那一层不受影响,所以循环会停在那里,而不是一路清到根。这种「只清受影响的最近一层」的写法,和前面脏标记的思路是同一个路子。

// renderer_canvas_cull.cpp:185–190(master 快照 2026-10-08)
void RendererCanvasCull::_mark_ysort_dirty(RendererCanvasCull::Item *ysort_owner) {
    do {
        ysort_owner->ysort_children_count = -1;
        ysort_owner = canvas_item_owner.owns(ysort_owner->parent) ? canvas_item_owner.get_or_null(ysort_owner->parent) : nullptr;
    } while (ysort_owner && ysort_owner->sort_y);
}
renderer_canvas_cull.cpp:167–190 原文:_count_ysort_childr…
图 4-22renderer_canvas_cull.cpp 第 167 到 190 行原文:数一遍与沿父链清缓存@ godot master 快照(2026-10-08)原文截取 · MIT

那个 while (ysort_owner && ysort_owner->sort_y) 就是「只清到最近的 y-sort 祖先」:再往上那一层不受影响,所以不必继续清。

5.4 例外里的例外:z 会静默压过 y 排序

言归正传,社区里流传一句结论:「y 排序只对 z 相同的项生效」。这句话我们需要实测而不是照抄,因为它是第三方(一位开发者在 4.7 上跑了逐像素断言)的结论。

本机探针 D1–D3:按最终 y 排、给 y 小的那个加 z 会压过 y 排序、同 y 时保持稳定
图 4-23本机探针 D1–D3:按最终 y 排、给 y 小的那个加 z 会压过 y 排序、同 y 时保持稳定@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)

本机三组读数是这样的:

例 设置 中心点结果
D1 父开 y 排序;红 y = 210、绿 y = 150 红(y 大者后画、在上)
D2 同上,另给绿加 z_index = 1(红为 0) 绿(z 压过了 y 排序)
D3 父开 y 排序;两者 y 都是 180 绿(后加者在上,稳定)

⇒ 「z 会压过 y 排序」在本机复现成立,D3 也印证了同 y 的稳定性。按源码看这也说得通:拍平之后每个成员仍然带着自己的绝对 z 回去装箱,而装箱发生在排序之后——y 只决定同一批项的收集顺序,z 才决定它们落在哪个桶。

注意:一处口径要写清:那条第三方结论来自 4.7,本篇只把它当作待验问题,结论以本机 D2 为准;不给跨版本结论。

5.5 y 排序的开销口径:贵在哪、便宜在哪

好了,把前面几段串起来,y 排序的代价可以拆成四项:

项 代价 何时付
数一遍子孙数 O(子树规模) 递归 只在缓存失效后付一次
拍平收集 O(子树规模) 每次都要(数组走栈上 alloca)
排序 O(k log k),k 为拍平后成员数 每次都要
清缓存 沿父链走到最近的 y-sort 祖先 只在结构变化时付

⇒ 与 z 桶那条主路对比:z 桶是零比较,y 排序是每次都要排一次。这就是为什么 y 排序只在真的需要「按前后遮挡」时才该开——它换掉的正是主路最擅长的东西。

这里还有一条不能省略的历史背景。官方在 2020 年那篇《Godot's 2D engine gets several improvements for upcoming 4.0》里同时宣布了两件事:新增 CanvasGroup("merges the draw calls of its children nodes"),以及 clip children 的成本几乎为零("happens at literally no cost")。但y 排序的性能问题是后来才修的:2022 年前后(4.0 开发期)它在大图块地图上一直很贵,直到 2023 年 4.2 的一条改动——

"2D: Greatly improve Y-sort performance on TileMaps (GH-73813)."

才把 TileMap(瓦片地图)上的 y 排序开销压下来。社区里能查到的另一组实测来自更早的 3.x 时代:纯 TileMap 在 100×100 时约 50 fps、512×512 时直接崩,且「512 图块的地图不开 YSort 占 170 MB,468 图块开 YSort 逼近 400 MB」。

注意:那组数字是3.x 口径,本篇只作前后景引用,不当 4.x 的性能结论;它与上面那条 4.2 的修补连起来,只说明一件事:「y 排序便宜」不是天生成立的,是被修出来的。

本章出处:实现同见 renderer_canvas_cull.cpp;三条外部依据分别是 4.2 开发快照的发行说明(y 排序性能修补那条)、修复它的 GH-73813,以及社区那组 TileMap 实测(3.x 口径,仅作前后景)。本机读数来自探针 D1–D3。

六、「画什么」的五道闸门

前面五章讲的是「什么顺序」。从这一章起换到另一个问题:哪些东西根本不会进入那条链。先来看看编辑器里与可见性相关的那一节长什么样。

编辑器界面:检查器里的 Visibility 一节,可见开关就集中在这里
图 4-24Godot 3.4 编辑器界面:检查器里的 Visibility 一节(OCR 可读出 Visibility 与 Inspector)@ Wikimedia Commons(作者见 image-credits.json;CC BY 3.0,署名要求;原图未改动)

这一节里的开关对应本章前面的两道闸门:Visible 与可见层(后者在 4.x 里是那组 32 位复选框)。而本章最容易被忽略的第三道闸门——累计透明度——在编辑器里根本没有专门的字段,它是父链 modulate 一路乘下来的结果;第四道(裁剪退化)也不在这一节。注意:同样是版本披露:这张图是 Godot 3.4 的界面。

6.1 两道在递归之前就拦下的闸门

_cull_canvas_item() 是个递归函数(cull.cpp:304 起),它从画布根往下走,每一项都要先过判据、再决定要不要继续看子项。而在它开头的十几行里,就连着过了两道门——这两道门的位置很关键,因为它们的 return 都写在递归调用之前。

// renderer_canvas_cull.cpp:305–313(master 快照 2026-10-08)
    Item *ci = p_canvas_item;

    if (!ci->visible) {
        return;
    }

    if (!(ci->visibility_layer & p_canvas_cull_mask)) {
        return;
    }
renderer_canvas_cull.cpp:307–313 原文:visible 与可见层两道闸门(都…
图 4-25renderer_canvas_cull.cpp 第 307 到 313 行原文:两道在递归之前返回的闸门@ godot master 快照(2026-10-08)原文截取 · MIT

注意两处 return 的位置:它们在递归之前。所以这两道门一旦不过,出去的不是「这一项不画」,而是这一项连同它的整棵子树一起出局。

第二道门的判据是按位与:visibility_layer 是节点上的一个位域,p_canvas_cull_mask 是视口上的一个位域(Viewport.canvas_cull_mask,默认 0xffffffff,见 renderer_viewport.h:122),两者只要有一位重合就放行。

官方类参考的原句是这样的:

"A Viewport will render a CanvasItem if it and all its parents share a layer with the Viewport's canvas cull mask."

「and all its parents」这四个字是这一节的重点,下一小节用探针把它钉住。

6.2 另外两道:全透明与退化裁剪

OK,再往下走,还有两道同样会连坐整棵子树的门:

// renderer_canvas_cull.cpp:327–331 与 :419–422(master 快照 2026-10-08)
    Color modulate = ci->modulate * p_modulate;

    if (modulate.a < 0.007) {
        return;
    }
    ...
        if (ci->final_clip_rect.size.width < 0.5 || ci->final_clip_rect.size.height < 0.5) {
            // The clip rect area is 0, so don't draw the item.
            return;
        }
renderer_canvas_cull.cpp:329–331 / 419–422 原文:累计 alpha 小…
图 4-26renderer_canvas_cull.cpp 第 329 到 331 与第 419 到 422 行原文:累计 alpha 与退化裁剪两道门@ godot master 快照(2026-10-08)原文截取 · MIT

第三条门最容易被忽略:判的是累加后的 modulate.a,也就是「父链一路乘下来」的透明度。⇒ 一个父节点把 modulate.a 调成 0,它的整棵子树都会消失,哪怕子节点自己的 alpha 还是 1。这不是 bug,是递归的自然结果。

第四条门说的是一种很常见的情形:clip_children 开了、但可裁剪区域退化到 0(例如父项自身没有任何可见内容)。这时候同样整棵子树不画。

6.3 第五条:只管自己进不进桶

第五条门不在上面那个位置,而在装箱那一步(_attach_canvas_item_for_draw()):

// renderer_canvas_cull.cpp:260(master 快照 2026-10-08)
    if (((ci->commands != nullptr || ci->visibility_notifier) && p_clip_rect.intersects(p_global_rect, true)) || ci->vp_render || ci->copy_back_buffer) {

判据是视口裁剪矩形与这一项的包围盒是否相交,用的是 Rect2::intersects(..., true)(第二个参数包含边界)。不过它只决定这一项自己进不进桶——因为在这一步之前,它的子项已经被处理过了。

注意:这里要澄清一个很常见的误读:2D 剔除不是视锥剔除。本篇的 165 处源码落点里没有任何相机视锥或平面,与「画什么」相关的几何判据只有这一条矩形相交;z 只决定顺序,不决定去留。

6.4 五道闸门的全貌

五道闸门流程图,并标出哪四道是整棵出局、哪一道只管自己进不进桶
图 4-27五道闸门流程图,并标出哪四道是整棵出局、哪一道只管自己进不进桶@ 本系列绘制
# 闸门 落点 出局范围
① visible 为假 cull.cpp:307 整棵子树
② 可见层与视口掩码按位与为 0 cull.cpp:311 整棵子树
③ 累计 alpha < 0.007 cull.cpp:329 整棵子树
④ 裁剪矩形退化(宽或高 < 0.5) cull.cpp:419 整棵子树
⑤ 与视口裁剪矩形不相交 cull.cpp:260 只影响自己进不进桶

依然是惯例,这张表建议对照源码各读一遍。它不是「性能优化清单」,而是「为什么我的节点不见了」的排查顺序:前四道门一旦不过,出局的是整棵子树,所以一旦发现某个父节点下面的子项集体消失,就该从这四道门里找原因,而不是逐个去检查子项自己的属性。

6.5 本机对照:链上任一环不匹配,整棵出局

OK,这一节的实测证据来得有点曲折:首跑时它判我错。

探针里有一例:视口掩码设成 0b10,父项 visibility_layer = 0b11、子项 = 0b10,按「按位与非零」的规则,父与子都该放行。可中心点读回来是背景色——什么都没画。查下去才发现根因不在被测节点上,而在探针自己的场景根节点:它的 visibility_layer 是默认值 1(canvas_item.h:108),而掩码 0b10 里没有第 0 位——于是整个场景在根那一层就被拦掉了。

这正好把「and all its parents」演成了实例。修掉装置(把装置自身的层放开)之后,又补了两例专门钉住它:

本机探针 B0–B6:必须整条链都匹配;中间夹一层不匹配的节点,父与子都对也照样整棵出局
图 4-28本机探针 B0–B6:必须整条链都匹配;中间夹一层不匹配的节点,父与子都对也照样整棵出局@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)
例 遮罩 链上各层的位 结果
B0 0b11 父 0b01、子 0b10 画出(各有重合位)
B1 0b10 父 0b01(不匹配)、子 0b10 整棵出局
B2 0b10 父 0b11、子 0b10 画出
B3 0b10 父 0b11、子 0b01(不匹配) 整棵出局
B4 0 父与子都匹配 整棵出局(视口一位都不要)
B5 0b10 中间层 0b01(不匹配)、父 0b11、子 0b10 整棵出局
B6 0b10 中间层 0b10、父 0b11、子 0b10 画出

B5 与 B6 只差中间层的一个位,结果一个全遮、一个正常。⇒ 「可见层必须整条链匹配」在本机成立。

还有一处旧印象要一并纠正:visibility_layer 的默认值两侧并不一样——场景侧是 1(只开第 1 位),服务侧 Item 的默认是 0xffffffff(全开)。这不是笔误:节点进画布时会把自己那一位下发下去(canvas_item.cpp:270 / :295),所以服务侧的默认值只对「没被场景层管过的项」生效。我最初就是按「两侧都是全开」的旧印象设计装置的,才踩了上面那个坑。

6.6 排查手册:节点不见了,按这五道门逐条查

好了,把上面那张表倒过来用,就是一份能对着敲的排查清单。顺序不要打乱——从整棵出局的门往后查,比从「样式对不对」查起快得多:

步 先看什么 怎么确认 典型现象
1 自己或任一祖先的 visible 编辑器的可见性眼睛图标会连坐 整棵子树都不见
2 自己与整条父链的 visibility_layer,以及视口的 canvas_cull_mask 两者按位与,任一环为 0 就出局 只有某个子视口里看不到
3 父链累加的 modulate.a 逐级相乘,注意父节点设了 0 或者极小值 子节点明明 alpha 是 1 也不见
4 祖先里的 clip_children 与它自身的可见区域 裁剪区域退化成 0 就整棵不画 只有开了裁剪的那一支不见
5 该项自己的包围盒与视口裁剪矩形 第 5 道门只管自己 只有超出某一范围的那几个不见

这五步里,前三步都是「整棵出局」,这一点是排查效率的关键:如果问题是「整棵子树都不见」,就从第 1 步开始;如果只有个别节点不见,直接跳到第 5 步。回头看,本篇探针首跑那次误判(0b10 掩码下场景整棵消失),根因正是第 2 步里我没查链条最外层的那一环。

本章出处:五道闸门同见 renderer_canvas_cull.cpp。可见层与画布变换的关系见官方 2D 变换说明;2D 光照里那套同样按掩码选对象的写法见官方 2D 光照与阴影。本机读数来自探针 B0–B6。

七、两级结构:先剔完整棵,再把桶串成一条链交出去

OK,这一章把整条链收口:剔除器对外只有一个入口,出口只有一条链。入口是 render_canvas(),出口是那条把 8193 个桶串起来的链表;把这两个端点看住,也就看住了这一层与上下游之间的全部契约。

7.1 入口与出口

// renderer_canvas_cull.cpp:495–508(master 快照 2026-10-08)
void RendererCanvasCull::render_canvas(RID p_render_target, Canvas *p_canvas, const Transform2D &p_transform, ...) {
    sdf_used = false;
    snapping_2d_transforms_to_pixel = p_snap_2d_transforms_to_pixel;

    if (p_canvas->children_order_dirty) {
        p_canvas->child_items.sort();
        p_canvas->children_order_dirty = false;
    }

    int l = p_canvas->child_items.size();
    Canvas::ChildItem *ci = p_canvas->child_items.ptrw();

    _render_canvas_item_tree(p_render_target, ci, l, p_transform, p_clip_rect, p_canvas->modulate, ...);
}
renderer_canvas_cull.cpp:495–508 原文:render_canvas 先排画布…
图 4-29renderer_canvas_cull.cpp 第 495 到 508 行原文:render_canvas 把画布项整理好,交给下一层@ godot master 快照(2026-10-08)原文截取 · MIT

出口在 _render_canvas_item_tree() 的最后一行:

// renderer_canvas_cull.cpp:104–105(master 快照 2026-10-08)
    RSG::canvas_render->canvas_render_items(p_to_render_target, list, p_modulate, p_lights, p_directional_lights, p_transform, p_default_filter, p_default_repeat, p_snap_2d_vertices_to_pixel, sdf_flag, r_render_info);

第二个参数就是那条链的表头 list。下游拿到的不是数组,是一条单链表——这一条决定了后续所有消费方的写法:它们只能顺着 next 顺序遍历,不能按下标随机取用;而顺序恰好就是上游刚刚排好的那个顺序,不需要再解释一次。

7.2 交接面:一个 Item,两个模块共用

这条链的节点类型值得单独说一句。剔除层的 Item 继承了渲染层的 Item:

// renderer_canvas_cull.h:45(master 快照 2026-10-08)
    struct Item : public RendererCanvasRender::Item {
cull.cpp:105 与 renderer_canvas_render.h:340/350/373 原文…
图 4-30renderer_canvas_render.h 第 340 到 373 行原文:下游读到的字段 z_final / next / global_rect_cache@ godot master 快照(2026-10-08)原文截取 · MIT

所以下游读的是同一块内存,只是字段的口径不同:next 是桶内链表指针(renderer_canvas_render.h:350),z_final 是剔除算出的最终 z(:340),global_rect_cache 是这一项的全局矩形(:373)。剔除层负责算,渲染层负责读——这就是两篇之间的接缝。

顺带记一个容易混淆的命名:RSG 是宏别名(#define RSG RenderingServerGlobals),它下面挂着两个不同的单例——RSG::canvas 是剔除器,RSG::canvas_render 是渲染抽象层(rendering_server_globals.h:62 / :65)。名字差一个后缀,职责差一层。

7.3 两级结构的代价与收益

把前面两小节拼起来看,剔除这一层实际做了两件性质不同的事,它们各自有独立的代价与收益。分开算,才能看清哪一部分是固定的、哪一部分随场景规模增长。

两级结构:先剔完整棵(一层递归),再把桶串成一条链交出去(一层汇总)
图 4-31两级结构:先剔完整棵(一层递归),再把桶串成一条链交出去(一层汇总)@ 本系列绘制
项 做法 收益
第一级:递归剔除 一个函数自递归,逐项过五道门 判据集中,出局即剪枝
第二级:桶汇总 一次 8193 桶扫描,串链 顺序零比较,O(1) 入桶
交接 一条单链表 下游顺序读,不必关心层级
每帧固定开销 两次 memset(≈128 KiB) 与场景规模无关

7.4 本机对照:同内容三种组织方式的读数

本机探针 E1–E3:同一批 200 个方块,无组、套组、开 y 排序三种组织方式的 draw call 读数对照
图 4-32本机探针 E1–E3:同一批 200 个方块,无组、套组、开 y 排序三种组织方式的 draw call 读数对照@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)
例 组织方式 draw call primitives objects
E1 200 个小方块,无组、不排序 1 400 1691
E2 同样 200 个,套一个 CanvasGroup 2 402 1692
E3 同样 200 个,父节点开 y 排序 1 400 1692

注意:这张表里藏着本篇的第三处口径纠正:CanvasGroup 并不省 draw call。200 个同材质方块本来就被批成 1 个 draw call,套上组反而变成 2 个(组要多一次背缓冲拷贝)。下一章按「代价」而不是「好处」来讲它。

7.5 为什么交给下游的是链表,不是数组

交接面的形状不是随手定的,三个理由都在源码里留着痕迹:

  1. 顺序已经编码在链里了。桶汇总做的事就是「把非空桶按 z 从小到大接起来」,接完之后顺序只体现在 next 指针上——再转成数组等于把这份信息重新编码一次,纯属浪费。
  2. 下游是流式消费的。渲染抽象层拿到链头之后顺序读下去即可,它不需要按下标随机访问;而链表天然支持「遍历途中再插一项」(后面讲组与背缓冲时就会用到这个能力)。
  3. 桶内本来就是链。入桶用的是尾指针追加(cull.cpp:276–283),所以桶里的项天生就是链表节点;汇总只是把这些小链接成大链,零拷贝。

⇒ 这条接缝一句话就能说清:上游负责「排序」,下游负责「消费」,中间用一条链传递,谁也不多做一次转换。这也是为什么本篇的边界停在 Item 这个类型上——它是两层共用的那一块内存。

本章出处:renderer_canvas_render.h、rendering_server_globals.h、rendering_server_default.h(均为 master 快照 2026-10-08)。本机读数来自探针 E1–E3。

八、CanvasGroup 的隔离要付什么

这一章讲例外二。CanvasGroup 的官方定义只有一句:"Merges several 2D nodes into a single draw operation."——「把若干 2D 节点合并成一次绘制操作」。可它买到的到底是什么、代价落在那儿,要一行行看。先看它在编辑器里是什么样子。

编辑器界面:CanvasGroup 只是场景树里的一个普通节点,它的调节项在检查器里
图 4-33Godot 4 编辑器界面(法语):场景树与检查器——CanvasGroup 在里面只是一个普通节点@ Wikimedia Commons(作者见 image-credits.json;CC BY 4.0,署名要求;原图未改动)

注意:这里有一处取图的如实交代:阶段 2 计划里,这一位想找一张「界面上能看清 Fit Margin / Clear Margin / Use Mipmaps」的截图,但整池 OCR 逐张查过之后没有一张能确认这些字段可见(本机也没有可用的 Godot 4 截图环境出图)。所以这一位按 §1.10 的处置办法换成了同类的真实界面图(如实标注为「组在场景树里只是个普通节点」),没有用自绘顶替;那三个字段的语义以正文的源码与官方类参考口径为准。

8.1 组什么时候启用

// renderer_canvas_cull.cpp:471–477(master 快照 2026-10-08)
    } else {
        RendererCanvasRender::Item *canvas_group_from = nullptr;
        bool use_canvas_group = ci->canvas_group != nullptr && (ci->canvas_group->fit_empty || ci->commands != nullptr);
        if (use_canvas_group) {
            int zidx = p_z - RSE::CANVAS_ITEM_Z_MIN;
            canvas_group_from = r_z_last_list[zidx];
        }

依然是惯例,先看第一道判据:组自己得有绘制内容,或者允许「空组也报一份尺寸」(fit_empty)。一个既没有绘制命令、又不允许空组自适应的 CanvasGroup,走的是普通路径——这一条解释了为什么「空的组什么也不做」。

8.2 组的包围盒是算出来的

组的可见区域不是用户设的,是由组内成员并出来的:

// renderer_canvas_cull.cpp:209–221(master 快照 2026-10-08)
            Rect2 rect_accum;
            RendererCanvasRender::Item *c = r_canvas_group_from;
            while (c) {
                if (c == r_canvas_group_from) {
                    rect_accum = c->global_rect_cache;
                } else {
                    rect_accum = rect_accum.merge(c->global_rect_cache);
                }

                c = c->next;
            }
renderer_canvas_cull.cpp:197–257 原文:组的包围盒等于组内绘制项 global…
图 4-34renderer_canvas_cull.cpp 第 209 到 221 行原文:组的包围盒是组内各绘制项全局矩形的逐次并集@ godot master 快照(2026-10-08)原文截取 · MIT

并完之后还要按 clear_margin 再外扩一圈,那是留给滤波与半透明边缘的余量(cull.cpp:243 / :249)。⇒ 组越大、余量越大,占用的背缓冲就越大。官方类参考里的两句提醒正是冲这个来的:fit_margin "increases both the backbuffer area used and the area covered by the CanvasGroup both of which can reduce performance",clear_margin 则相反——"A smaller margin will reduce the area of the backbuffer used which can increase performance"。

8.3 组的 α 与普通父节点的 α 不是一回事

好了,这是本篇最反直觉的一条实测。构造两组对比:都是「两个重叠的红块」,区别只在父节点是普通的 Node2D 还是 CanvasGroup,且父节点的 modulate.a 都设成 0.5。

本机探针 C1–C4 与 C3a/C3b:组的 alpha 施加在合成结果上(与普通父节点不等价)、clip_ch…
图 4-35本机探针 C1–C4 与 C3a/C3b:组的 alpha 施加在合成结果上(与普通父节点不等价)、clip_children 在组内失效@ 本机实测 · Godot 4.8.dev.custom_build.7177493c1(自定义构建)
例 结构 中心点红分量 说明
C1 不用组,两个 α = 0.5 红块重叠 0.749 基准
C2 普通父节点 α = 0.5,两个不透明红块 0.749 与 C1 同值
C3 CanvasGroup α = 0.5,两个不透明红块 0.373 ≈ 0.749 × 0.5
C4 CanvasGroup α = 1,组内两个 α = 0.5 红块 0.749 与 C1 同值

C2 与 C3 的差别只在「父是普通节点还是组」,结果却从 0.749 掉到 0.373。⇒ 组的 α 施加在「组内已经合成好的结果」之上再作用一次:子项先按继承来的 α 合成到背缓冲(得到 0.749),组主再把这份结果整体贴回父画布(再乘 0.5)。

这说明「整体合成」是真的:C4 里组自身 α 为 1 时,组内合成与无组完全一致(都是 0.749);一旦组自身要半透明,那一次透明就落在整份结果上,而不是落在每个子项上。注意:这也是本篇要改的口径——社区常说「组会合并 draw call」,本机读数(E2)恰恰相反:它买到的是「一次整体合成」,不是省 draw call。

8.4 clip_children 在组内会失效

同一批探针里还有一组对照:一个 20×20 的遮罩节点开 clip_children,它下面挂一个 120×120 的子项。

例 结构 遮罩外的采样点
C3a 组外,遮罩开 CLIP_CHILDREN_AND_DRAW 背景色(被裁掉了)
C3b 同样结构放进 CanvasGroup 蓝(120×120 的子项整块露出来)

⇒ clip_children 在组外生效、在组内失效。这不是实现漏洞,而是背缓冲只有一份的必然结果,源码里那条注释把话说明白了:

// renderer_canvas_cull.cpp:254–256(master 快照 2026-10-08)
            // Very important that this is cleared after used in RendererCanvasRender to avoid
            // potential crashes.
            r_canvas_group_from->canvas_group_owner = ci;

8.5 两条「不会正常工作」的告警

再看两条告警。CanvasGroup 节点自己会报两条配置告警:

// scene/2d/canvas_group.cpp:78–88(master 快照 2026-10-08)
            if (!warned_about_ancestor_clipping && as_canvas_item && as_canvas_item->get_clip_children_mode() != CLIP_CHILDREN_DISABLED) {
                warnings.push_back(vformat(RTR("Ancestor \"%s\" clips its children, so this CanvasGroup will not function properly."), as_canvas_item->get_name()));
                warned_about_ancestor_clipping = true;
            }

            CanvasGroup *as_canvas_group = Object::cast_to<CanvasGroup>(n);
            if (!warned_about_canvasgroup_ancestor && as_canvas_group) {
                warnings.push_back(vformat(RTR("Ancestor \"%s\" is a CanvasGroup, so this CanvasGroup will not function properly."), as_canvas_group->get_name()));
                warned_about_canvasgroup_ancestor = true;
            }
scene/2d/canvas_group.cpp:70–97 原文:两条「不会正常工作」的配置告警(祖先裁剪、…
图 4-36scene/2d/canvas_group.cpp 第 78 到 88 行原文:两条「不会正常工作」的配置告警@ godot master 快照(2026-10-08)原文截取 · MIT

两条告警与官方类参考那句 "Since CanvasGroup and CanvasItem.clip_children both utilize the backbuffer... will not function correctly" 指的是同一件事:背缓冲只有一份。

注意:一处负结果要如实记下:这两条告警在运行时读不到。本篇探针原本想直接读出告警字符串,实测报 Nonexistent function 'get_configuration_warnings' ——该虚函数没有绑定到脚本 API,只有编辑器会去调它。所以这一节的口径只作源码引用,不声称「本机验过告警」。

8.6 组的节点侧默认值

最后补两条节点侧的事实:CanvasGroup 构造时把 fit_margin 设成 10 px(canvas_group.cpp:119),并且它实际下发的是透明模式加空组自适应——CANVAS_GROUP_MODE_TRANSPARENT 配 fit_empty = true(canvas_group.cpp:40 / :53 / :64),析构时再关成 CANVAS_GROUP_MODE_DISABLED(:122)。枚举里一共四种模式(rendering_server_enums.h:847–851):DISABLED / CLIP_ONLY / CLIP_AND_DRAW / TRANSPARENT,而 CanvasGroup 节点只用了最后一种。

组的一次绘制时序:子项先画进背缓冲,组主再作为一项贴回父画布
图 4-37组的一次绘制时序:子项先画进背缓冲,组主再作为一项贴回父画布@ 本系列绘制

8.7 按代价推出来的四条使用建议

把前面六小节的结论换算成可执行的建议,四条都是从代价推出来的,不是手感:

  1. 只在「需要整体施加效果」时用组。组的价值是一次整体合成(C3 的 0.373 就是它的指纹),而不是省 draw call(E2 实测 1 → 2)。如果只是想省绘制开销,组不是工具。
  2. 余量按需要给,别按习惯给。fit_margin 与 clear_margin 默认都是 10 px,它们直接决定背缓冲多大;官方类参考的原话是 fit_margin "should be kept as small as possible"。
  3. 组内不要再开 clip_children。两条机制抢同一份背缓冲,实测结果是组内那一侧的裁剪彻底失效(C3a 对 C3b)。
  4. 不要嵌套组、也不要把组放进开了裁剪的祖先里。这两种情况节点自己会报配置告警——虽然那两条告警在运行时读不到,但编辑器里看得到。

注意:补一句关于第 4 条的口径:本篇没有用探针验证「告警会不会出现」,因为实测发现那个查询函数没有绑定到脚本 API(get_configuration_warnings() 报 Nonexistent function)。所以这一条的依据是源码里的告警分支,不是本机实测——证据层级要说清。

本章出处:canvas_group.cpp(master 快照 2026-10-08);组的立项动机见官方 2020 年那篇 4.0 二维引擎改进公告。本机读数来自探针 C1–C4 与 C3a/C3b。

九、收官:这套剔除与排序买到什么、代价是什么

OK,把九个章节的东西压成一张图,再看两处我们纠正过的旧印象。这一章不再引入新机制,只做三件事:把可量化的结论收拢成三条,把过程中纠正过的旧印象摆出来,以及把这条链往下交棒的位置指清楚。

全篇链条回看:五道闸门加三层箱位,以及 y 排序与画布组这两个「例外」
图 4-38全篇链条回看:五道闸门加三层箱位,以及 y 排序与画布组这两个「例外」@ 本系列绘制

9.1 三条量化结论

  1. 顺序零比较:8193 个桶、每帧 ≈128 KiB 清零、每项入桶 O(1)、汇总扫一遍 8193 个槽位——排序比较次数是 0。代价是 z 必须有硬边界(-4096 到 4096),因为没有边界就没有下标。
  2. 剔除是五道闸门,四道连坐整棵子树:visible、可见层、累计 alpha 小于 0.007、裁剪退化,四条都在递归之前 return;第五条(矩形相交)只管自己。
  3. CanvasGroup 买到的是整体合成,不是省 draw call:本机 200 个方块,无组 1 个 draw call、套组 2 个;组内合成的 α 施加位置与普通父节点不同(0.373 对 0.749)。

9.2 两处旧印象纠正

  • 「2D 剔除也有视锥剔除」——错。 本篇 165 处落点里没有任何相机视锥;几何判据只有视口裁剪矩形与包围盒的相交测试。2D 的「画什么」靠的是可见层掩码、透明度与矩形相交这三类判据。
  • 「层内绘制序号就是 layer、或者就是场景树里的位置」——错。 层内序号是三套来源:CanvasLayer 下的项取该层的发号器(return sort_index++)、其余项取视口的 GUI 发号器、需要改序的普通节点才用自己的树序。

还有一条口径值得留在结尾:本机的三条反直觉读数(组内 clip_children 失效、组的 α 二次作用、可见层必须整条链匹配)都来自 4.8.dev.custom_build.7177493c1 这个自定义构建,而源码行号取自 master 快照 2026-10-08。两者已漂移且漂移量逐文件不同,所以本文从不把本机行号当快照行号用,也不做算术平移。

9.3 这一层之后,链交给谁

剔除器的出口是一条链,下游接住它的是画布渲染抽象层。从 2020 年 4.0 的 2D 大改(CanvasGroup 就是那一批加的),到 2022 年前后 2D 光照与批处理的调整,再到 2023 年 4.2 对 TileMap y 排序性能的修补(GH-73813),这条链的形状一直在动,但「先剔完整棵、再把桶串成一条链」这个骨架没动过——它就是我们这一篇留下来的东西。

剔除与排序这条链在引擎里的位置,以及后续各篇各自负责的那一层(命令列表与依赖图、后端分叉、主循环与线程)
图 4-39剔除与排序这条链在引擎里的位置,以及后续各篇各自负责的那一层(命令列表与依赖图、后端分叉、主循环与线程)@ 本系列绘制

9.4 一条时间线:2D 剔除与排序被改过的几次

下面进入时间线:把散在各章的年份收拢起来,这条链的演进大致是这样(年份口径:以官方博客或发行说明的发布年为准,不按提交时间):

年份 变化 与本篇的关系
2020 官方博客宣布 4.0 的 2D 大改:新增 CanvasGroup、clip children 第八章那个「整体合成」机制的来历
2020 同一篇里 2D 光照改为单 pass,并给出可见上限(屏幕内 256 盏灯、每节点 16 盏) 剔除的「按掩码选谁受光」是同一套位域思路
2021 官方批处理文档给出把画家序打碎的三种原因(换纹理、换材质、换图元类型) 下一篇的入口
2022 4.0 正式版与 4.2 开发期,y 排序在大图块地图上的开销问题被持续报告 第五章 §5.5 的前后景
2023 4.2 修补 y 排序在 TileMap 上的性能(GH-73813) 「y 排序便宜」是被修出来的
2024 4.3 引入无环渲染图与 GPU 同步(官方博客) 第七篇的题目,本篇只到「交给下游一条链」
2026 本篇使用的 master 快照(2026-10-08)与自建引擎(7177493,2026-07-03) 所有行号与读数的时点

⇒ 时间线给出的结论是:这条链的骨架(先剔完整棵、再把桶串成一条链)从 4.0 到 4.6 没有变过,被反复调整的是外围——y 排序的性能、后端的批处理、设备层的同步方式。

回头看,这套设计的原理可以概括成一句,其中:结构承担了顺序,判据集中在了一处。

本章出处:时间线的依据依次是官方 2020 年 4.0 二维引擎改进公告、官方 3.x 批处理文档(画家序被打碎的三种原因)、4.2 开发快照发行说明与 4.2 beta 1 发行说明,以及官方 4.3 GPU 同步与无环渲染图那篇。另外两条社区线索只作前后景:论坛那条「同 z 看树序」的问答与那份 4.7 上的 y 排序断言套件。

9.5 术语对照

本篇出现的主要术语,中英并列,便于对照源码:

中文 English 在本篇指什么
剔除 culling 决定「画什么」的那一层(本篇)
剔除器 canvas culler RendererCanvasCull 这个实现类
可见层 visibility layer CanvasItem 上的 32 位位域
剔除掩码 canvas cull mask 视口上与可见层按位与的那个位域
绘制序号 draw index 决定的层内先后,来源有三套
z 桶 z bucket 按绝对 z 分箱的那 8193 个槽位
拍平 flatten y 排序前把子树摊成一维数组
背缓冲 backbuffer CanvasGroup 与 clip_children 争抢的那份缓冲
画布层 canvas layer CanvasLayer,层与层之间比 layer

相关链接与出处

本站与后续篇目:上一篇 服务层接口:RenderingServer 为什么是一组 RID 型命令;下一篇(第五篇)是画布渲染抽象:RendererCanvasRender 与一次绘制的中间表示——那条链交下去之后,绘制项里到底存了什么;本系列 T02《Godot 4 引擎架构》全 11 篇(一次 2D 绘制的四层落点)的目录见技术博客页。

出处与文档:正文引用的源码是 godotengine/godot master 快照(2026-10-08)原文,许可 MIT,核对脚本与输出现已随本篇归档;本机探针输出见 evidence/probe/E_order_visibility_group_ysort.txt(A–E 五组 21 条用例,一次窗口运行跑完)。官方文档:Canvas layers、CanvasItem、CanvasLayer、CanvasGroup、Viewport。

With Best Wishes.