技术博客

CanvasItemRenderer:2D 渲染的批处理 —— 上千个精灵怎么合批

第 148 / 149 篇 · 2026-03-23 · godot · 渲染管线2D 渲染源码分析

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) 区间。

八个断点:什么时候合批失效

实际项目里批处理被打断的典型场景,按遇到频率排:

  1. 交错的不同纹理——最常见,解法是同帧内把同纹理 item 排到一起(调整树顺序比换贴图代价小);
  2. 自定义 shader / shader_parameter 变化——哪怕同纹理,material 不同即断批;
  3. clip_children / 裁剪矩形——裁剪是状态的一部分,被裁剪的子树单独成批;
  4. CanvasItem 的 light_mode 变化——受光与不受光 item 走不同渲染路径;
  5. skeleton / 蒙皮——顶点要过骨骼纹理采样,与普通顶点不共享 pipeline;
  6. MSDF 字体与普通贴图字体混排;
  7. transform 跨画布(CanvasLayer / SubViewport)——不同 RT 天然断批;
  8. 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 也要重传几何。

一图流

2D 批处理:命令流 → 状态分组 → draw call item 命令流(按树序) A B A C B B 按状态排序合并 A×2 B×3 C 断批条件(八元组任一变化):texture / shader / material / blend filter / light_mode / clip / skeleton,以及换 RT(CanvasLayer) RD 后端:顶点常驻缓冲,transform 走 uniform 池 + instanced draw

实战推论

  • 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 的哈希查表)。