2D 场景一万个精灵还能跑 60 帧,靠的不是每个 Sprite 一个 draw call。Godot 的 2D 渲染走 RendererCanvasRender 抽象,RD 后端把一帧的 CanvasItem 命令流按渲染状态排序后合并成大 draw call。这篇拆批处理的判定条件、断点,以及"什么时候批处理救不了你"。
从 Item 到批次:一次 2D 帧的数据流
场景树每个 CanvasItem 的 _draw() 产物是一串渲染命令(RenderingServer::canvas_item_add_*:texture rect、polygon、line、primitive 等),存进 item 的命令数组。一帧的绘制分两步:
1. 收集:遍历可见 CanvasItem,按 z_index / y_sort / canvas_layer 排成全局 item 列表
2. 渲染:遍历 item 列表,把命令展开成 (state, geometry) 对,相邻同 state 合并为 batch
"state"是合批的唯一判据:{ shader, material, texture, filter, blend, light_mask, transform } 这八元组里任何一项变化都打断批次。所以一千个同贴图同材质的 Sprite 是 1 个 batch;一百个交替换贴图的 Sprite 是 200 个。2D 性能优化的第一条军规就是"把贴图打进图集",这不是玄学,是 batch 判据的直接推论。
geometry 来源分两类:命令自带的顶点(rect/polygon)直接进顶点缓冲;字体和图集走共享的缓冲。批次合并时顶点追加进一个每帧重置的动态缓冲,一个 batch 就是一次 draw 的 (base_vertex, count) 区间。
八个断点:什么时候合批失效
实际项目里批处理被打断的典型场景,按遇到频率排:
- 交错的不同纹理——最常见,解法是同帧内把同纹理 item 排到一起(调整树顺序比换贴图代价小);
- 自定义 shader / shader_parameter 变化——哪怕同纹理,material 不同即断批;
- clip_children / 裁剪矩形——裁剪是状态的一部分,被裁剪的子树单独成批;
- CanvasItem 的 light_mode 变化——受光与不受光 item 走不同渲染路径;
- skeleton / 蒙皮——顶点要过骨骼纹理采样,与普通顶点不共享 pipeline;
- MSDF 字体与普通贴图字体混排;
- transform 跨画布(CanvasLayer / SubViewport)——不同 RT 天然断批;
- primitives(line/primitive 类)与 textured geometry 之间——RD 后端的顶点和索引布局不同,永不合并。
注意第 8 条:调 _draw() 画 debug 线会把整个 canvas 的批处理劈成两半——线命令前后的纹理批互不合并。
静态优化:redraw 矩形与缓冲复用
每帧重建动态顶点缓冲对小场景无所谓,大场景(几万命令)成本可观。Godot 的缓解是 redraw 区域裁剪:canvas 命令按帧只做"dirty 矩形"内的重建,RenderingServer::canvas_item_set_update_when_visible 之类的机制配合 NOTIFICATION_DRAW 的局部请求。更进一步,canvas_item 有 use_identity_transform 等标记帮引擎判断 buffer 是否可跨帧复用(主要给 Compatibility 渲染器的遗留路径用,RD 后端依赖 uniform buffer 的 transform 方案,顶点本身不带变换)。
RD 后端的真实做法:顶点只存一次,变换走 uniform。每个 item 的 transform 是一个 3×2 矩阵写进每帧更新的 uniform 池,批次内同 state 的 item 用 instanced draw(每 instance 一个 transform slot)——这把"几何合并"降级为"状态合并 + instancing",顶点数据跨帧复用率大幅提升。这是 4.x 重写 2D 渲染器时从 3.x 吸取的核心教训:3.x 的批处理是每帧拼一个大顶点缓冲,改 transform 也要重传几何。
一图流
实战推论
- draw call 数 = 状态变化次数,不是节点数。排查 2D 掉帧先看 RenderingServer 的 draw call 统计,再看是谁在打断状态:九成交错纹理,一成自定义材质。
draw_line/draw_primitive做 debug 显示会切批——发布版本关掉,或者集中到单独 CanvasLayer(它本来就要断批,把损失限制在一层内)。- 大场景别依赖每帧
_draw:用queue_redraw的局部 dirty 区域,静态区域设top_level独立 item 减少无效重绘。
本文基于 Godot 4.x 源码:servers/rendering/renderer_canvas_render.h(抽象接口)、servers/rendering/renderer_rd/renderer_canvas_render_rd.cpp(RD 后端的批次与 uniform 池)、servers/rendering/renderer_rd/storage_rd/pipeline_set_cache_rd.h(state → pipeline 的哈希查表)。