“像素山谷”工作室的渲染主程小葵最近很头疼。新机型上,她精心打造的《山谷小镇》掉帧严重,从 60 帧跌到 20 帧。奇怪的是,GPU 这颗“显卡心脏”的利用率只有 30%,看起来闲得很,CPU 却忙得快要冒烟。她请来资深的 GPU 师傅“大黄”诊断,大黄只看了一眼渲染日志就笑了:“你一帧给我发了 800 单,每单都要我停车、核对地址、重新热车。我这辆卡车再能装,也经不起你把它当出租车开呀。” 小葵恍然大悟:在 3D 渲染管线里,CPU 是“调度员”,GPU 是“卡车司机”。而 Draw Call,就是 CPU 每帧向 GPU 下达的一次“发车指令”——“把这个物体从这个位置画出来”。每一条指令都有固定开销:校验数据、切换状态、打包命令。物体越多、指令越多,CPU 越忙;卡车虽然快,却要一趟趟空等。解决思路也像快递一样朴素:把去往同一个方向、使用同一种“车厢”(材质)的包裹,装进同一个集装箱,一次发车。这,就是 Draw Call 批处理。 本文从小葵的“集装箱改造日记”出发,带你理解批处理的基本原理、三种主流方案(静态合批、动态合批、GPU 实例化)、配套的纹理图集技巧,以及工程实战中的取舍与排错。看完你就能明白:为什么 GPU 很闲、游戏却卡成 PPT,以及如何让帧率起死回生。

岗位关联

学会 Draw Call 批处理,你就掌握了实时渲染性能优化的“第一性原理”。无论做游戏、数字孪生还是 AI 生成内容的可视化落地,这项技能都能让你的渲染帧率产生肉眼可见的提升,是图形与可视化工程师晋级路上的必修课。

目录
  1. 可视化演示
  2. 基本概念:一帧就是一班送货周期
  3. 批处理原理:为什么集装箱能省钱
  4. 静态批处理:把不挪窝的家具焊成一体
  5. 动态批处理:每帧现拼现发的拼箱服务
  6. GPU实例化:一张清单种出千棵树
  7. 纹理图集:一面墙贴满所有招牌
  8. 工程实践:先看行车记录仪,再动手装箱
  9. 问题排查:合批失败与收益评估
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

基本概念:一帧就是一班送货周期

《山谷小镇》上新机型的第一天,小葵就被玩家的吐槽淹没:“卡成 PPT 了!”她打开 Profiler 一看,CPU 在渲染线程上耗了 14ms,GPU 却只忙了 4ms,而 60 帧留给一帧的时间只有 16.7ms。大黄师傅叼着烟点评:“这不是车跑得慢,是你发车发得太多了。”

在 3D 渲染管线里,CPU 负责“想清楚画什么”,GPU 负责“动手画出来”。想清楚画什么的结果,就是一条条绘制命令:画这个网格、用这个材质、放在这个位置。每一条命令,就叫一个 Draw Call(绘制调用)——你可以把它想象成调度员对司机喊出的一句“发车口令”。一帧要画的物体越多,Draw Call 就越多,就像快递站要发的单子越多,调度员要做的派单动作就越繁琐。

很多人以为渲染慢就是 GPU 不行,但在物体数量庞大的场景里,真正的瓶颈往往是 CPU。每次 Draw Call,CPU 都要做驱动校验、状态检查、命令打包,这些工作在物体很小的时候也一笔不少:

所以小葵的任务很清晰:让 CPU 少发车。批处理,就是把几十上百个零散的“小包裹”合并成几趟“大集装箱”,让调度员省下大把时间。接下来的每一节,都是小葵的装箱实战记录。

Tframe=Ndraw×Ccall+Tgpu\displaystyle T_{\text{frame}} = N_{\text{draw}} \times C_{\text{call}} + T_{\text{gpu}}
这个公式告诉你:一帧的总耗时,约等于“Draw Call 数量 × 单次调用开销”再加上 GPU 真正干活的时间。当 Draw Call 数量大到让 CPU 超过 16.7ms 预算时,GPU 再快也白搭——它只能干等指令。这种“CPU 忙、GPU 闲”的现象叫 CPU-bound,是 Draw Call 过多最典型的信号。
  1. 帧总耗时 \(T_{\text{frame}}\)渲染一帧画面花掉的总时间,它必须小于 16.7ms 才能保住 60 帧每秒。它由 CPU 准备时间和 GPU 执行时间两部分构成。
    Tframe=Tcpu+Tgpu\displaystyle T_{\text{frame}} = T_{\text{cpu}} + T_{\text{gpu}}
  2. Draw Call 数量 \(N_{\text{draw}}\)一帧里 CPU 发出的绘制指令条数。物体越多、拆得越碎,这个数就越大,是优化最该盯住的变量。
    Ndraw=#{待绘制物体}\displaystyle N_{\text{draw}} = \#\{\text{待绘制物体}\}
  3. 单次调用开销 \(C_{\text{call}}\)每条指令的固定成本,包括驱动校验、状态检查与命令打包,它与物体大小无关,哪怕画一个点也要付这么多。
    Ccall=Cdriver+Cstate\displaystyle C_{\text{call}} = C_{\text{driver}} + C_{\text{state}}
  4. GPU 渲染耗时 \(T_{\text{gpu}}\)GPU 真正执行光栅化、着色、写帧缓冲的时间,这部分通常与三角形数量成正比,而不是与物体个数成正比。
    Tgpu∝Ntriangles\displaystyle T_{\text{gpu}} \propto N_{\text{triangles}}

批处理原理:为什么集装箱能省钱

小葵在调度室蹲了一夜,把大黄每次发车的动作拆解开:核验派单表(驱动校验)、检查车厢状态(渲染状态检查)、把包裹搬上车(命令打包)、发车(提交命令)。她惊愕地发现,不管包裹多重,这四步动作一样都不能少——这就是 Draw Call 的固定开销。既然每单都要付一次“起步价”,那把多个包裹塞进同一辆车,起步价不就只付一次了吗?

批处理的思路,就是让一次发车同时拉走多个包裹,从而摊薄这份固定开销。但包裹不能随便捆:GPU 的“车厢”由材质决定,换了材质,就要重新喷漆、重新布置车厢,这是所有状态切换里最贵的一种。所以合批的第一铁律是——同材质才能同车。渲染引擎的渲染队列按材质排序,原理也是如此:让同材质的物体聚在一起,减少“换车厢”的次数。材质、网格、顶点格式等条件共同决定了合并策略的分类,也就是后面要讲的静态合批、动态合批与 GPU 实例化三兄弟。

如果把一个 Draw Call 比作一次发车,批处理就是快递行业的集装箱革命:同样的货物,发车次数从一千降到了几十,调度员终于能喝口茶了。接下来,小葵会给你展示三种装箱方案:焊死的、现拼的、还有递清单的。

Nbatch=⌈NobjKbatch⌉\displaystyle N_{\text{batch}} = \left\lceil \frac{N_{\text{obj}}}{K_{\text{batch}}} \right\rceil
这个公式是“集装箱装箱数”的数学表达:物体总数除以单批容量,再向上取整。它告诉我们,合批的目标就是大幅压低这个 \(N_{\text{batch}}\),让它取代原来那个巨大的 \(N_{\text{draw}}\) 进入帧耗时公式。
  1. 物体总数 \(N_{\text{obj}}\)场景中需要绘制的物体个数,是合批的输入原材料,一帧 1000 个物体就是 1000 件待装车的包裹。
    Nobj=场景待绘物体总数\displaystyle N_{\text{obj}} = \text{场景待绘物体总数}
  2. 单批容量 \(K_{\text{batch}}\)一个批次最多能装下的物体数量,受顶点数上限、材质一致、网格可合并性等条件约束,是“集装箱”的容积。
    Kbatch=min⁡(Kvertex,Kmaterial,Kmesh)\displaystyle K_{\text{batch}} = \min(K_{\text{vertex}}, K_{\text{material}}, K_{\text{mesh}})
  3. 向上取整 \(\lceil \cdot \rceil\)就算最后一箱只装了一件包裹,也得发一趟车,所以批次数必须向上取整,不能四舍五入。
    ⌈94⌉=3\displaystyle \left\lceil \frac{9}{4} \right\rceil = 3
  4. 合并后批次数 \(N_{\text{batch}}\)优化后的发车次数,它直接取代帧耗时公式里的 \(N_{\text{draw}}\),目标是把成百上千压到几十。
    Nbatch≪Nobj\displaystyle N_{\text{batch}} \ll N_{\text{obj}}

静态批处理:把不挪窝的家具焊成一体

小镇广场上,花坛、长椅、路灯都是“钉子户”,十年不挪窝。小葵想:既然这些包裹永远不会动,为什么不提前把它们焊成一体?于是她把广场上所有共享同一材质的静态物体,按区域焊成了几块“巨无霸”,从此大黄经过广场只需要停一次车。

这就是 静态批处理(Static Batching):在场景加载阶段,把不动的网格合并成一个大的组合网格,渲染时一次 Draw Call 画完。它的优点是“一劳永逸”——合并工作在运行前就完成了,运行时零额外消耗。但天下没有免费的午餐,焊死的大铁箱有两个代价:一是顶点数据要被复制进新的合并缓冲,内存近似翻倍;二是只能按合并后的整体做视锥剔除,不能再单独判断某把长椅是否在屏幕外。

“焊死的大铁箱”很能装,但占仓库,而且不能单独退货。所以静态合批最适合“永远不动、数量庞大、共享材质”的场景,比如室内装修、建筑外立面、城市街道设施——小葵的广场改造大获成功,Draw Call 从 400 降到了 50。

Mstatic=Mscene+Mmerged\displaystyle M_{\text{static}} = M_{\text{scene}} + M_{\text{merged}}
这个公式是说:静态合批后的总内存,等于场景原始内存加上合并缓冲内存。因为顶点数据是复制而不是移动,所以内存大约翻倍——这是用空间换时间。
  1. 场景原始内存 \(M_{\text{scene}}\)所有小网格原本占用的顶点与索引内存,是所有小包裹各自的“占地面积”之和。
    Mscene=∑i=1nVi×Svertex\displaystyle M_{\text{scene}} = \sum_{i=1}^{n} V_i \times S_{\text{vertex}}
  2. 合并缓冲内存 \(M_{\text{merged}}\)合批后新生成的大网格缓冲,顶点数据是从各处复制过来的,原始数据并不会消失。
    Mmerged≈Mscene\displaystyle M_{\text{merged}} \approx M_{\text{scene}}
  3. 静态合批总内存 \(M_{\text{static}}\)新旧两份同时存在,所以内存近似翻倍,这就是“焊死”要付的仓库租金。
    Mstatic≈2×Mscene\displaystyle M_{\text{static}} \approx 2 \times M_{\text{scene}}

动态批处理:每帧现拼现发的拼箱服务

街上的行人、小狗、飘动的旗子天天乱跑,不能焊死。小葵于是改成“当日现拼”:每天清晨,把要去同一方向的包裹临时装进一个集装箱,发完就拆。第二天路线变了,再重新拼。这就是动态批处理的日常。

动态批处理(Dynamic Batching) 每帧把符合条件的移动物体临时合并,提交一次 Draw Call。它的工作流程是:每帧把各物体的顶点变换到世界空间,写入一块临时合并缓冲,再一次性提交给 GPU。但有三个硬性限制:批次内总顶点数有上限(Unity 里大约是 900 个顶点)、参与合批的物体必须使用相同材质、骨骼蒙皮网格等特殊网格不能参与。超过上限,引擎只能默默拆成两个批次。

现拼现发有好处也有成本——拼箱本身要花 CPU 时间。如果包裹太大太多,拼箱的功夫可能比省下的发车还贵,所以动态合批在移动端要慎用。小葵给大黄的规矩是:小件、同材质、总数少,才值得每天现拼。

Vdyn=∑i=1kvi≤Vmax\displaystyle V_{\text{dyn}} = \sum_{i=1}^{k} v_i \le V_{\text{max}}
这个公式是一条“装箱约束”:同一批次累加的顶点数,不能超过动态合批的顶点预算 \(V_{\text{max}}\)。包装盒就这么大,塞不下就得再开一箱。
  1. 第 i 个物体顶点数 \(v_i\)每个参与合批的小物体自己的顶点数量,是包裹的体积。
    vi=物体 i 的顶点数\displaystyle v_i = \text{物体 } i \text{ 的顶点数}
  2. 求和符号 \(\sum\)同一批次的顶点数要逐件累加,代表拼箱后包裹的总体积。
    Vdyn=v1+v2+⋯+vk\displaystyle V_{\text{dyn}} = v_1 + v_2 + \dots + v_k
  3. 顶点上限 \(V_{\text{max}}\)动态合批的顶点预算(如 900),超过这个数引擎无法用单批处理,只能另开一批。
    Vmax≈900\displaystyle V_{\text{max}} \approx 900
  4. 约束关系 \(\le\)它保证了“一箱装得下”是合批成立的前提,装不下时宁可拆箱也不要强塞。
    Vdyn≤Vmax⇔合批成立\displaystyle V_{\text{dyn}} \le V_{\text{max}} \Leftrightarrow \text{合批成立}

GPU实例化:一张清单种出千棵树

山谷里有 5000 株一模一样的薰衣草。小葵没把它们焊成一体,而是给大黄递了一张清单:“草长这样(网格),这是 5000 个种植坐标(变换矩阵),你按清单种。”大黄开着车按清单跑,一趟活全干完。这就是 GPU Instancing 的朴素思想。

GPU 实例化(Instancing) 的理念正是如此:网格只有一份,每帧只需提交“网格 + 实例数据表”(每个实例的变换矩阵、颜色等属性),GPU 内部循环绘制所有实例。与合批“合并顶点”不同,实例化是“复用顶点、只换属性”,所以它不需要把 5000 份顶点数据复制来复制去,CPU 和内存压力都小得多。实例数据通常存放在专门的实例缓冲(StructuredBuffer)里,GPU 每处理一个实例就读取一行数据。

与合批的“合并顶点数据”不同,实例化是“复用同一份顶点、只换属性”,所以它特别适合草、树、路灯、人群这类“同模型大量出现”的对象。小葵的薰衣草山谷,Draw Call 从 5000 直接降到了 1。

Binst=Ninst×Sdata\displaystyle B_{\text{inst}} = N_{\text{inst}} \times S_{\text{data}}
这个公式计算的是 GPU 读取实例数据消耗的显存带宽:实例数量乘以每个实例的数据量。它只跟实例数量有关,跟三角形数量无关,这正是实例化“轻装上阵”的原因。
  1. 实例数 \(N_{\text{inst}}\)需要绘制的副本总数,比如 5000 株薰衣草,这个数决定清单有多长。
    Ninst=5000\displaystyle N_{\text{inst}} = 5000
  2. 单实例数据量 \(S_{\text{data}}\)每个实例只需传变换矩阵和可选的颜色等属性,通常只有几十到一百多字节。
    Sdata≈64∼128 字节\displaystyle S_{\text{data}} \approx 64 \sim 128\ \text{字节}
  3. 总带宽 \(B_{\text{inst}}\)GPU 读取这份清单消耗的显存带宽,它控制着实例化方案的“运费”。
    Binst∝Ninst\displaystyle B_{\text{inst}} \propto N_{\text{inst}}

纹理图集:一面墙贴满所有招牌

小镇夜市有 20 家小店,每家招牌都是一块独立的布。大黄每画完一家,就要换一块布(纹理切换),换来换去刹车片都磨没了。小葵索性把 20 块招牌印到同一张巨型喷绘布上,画整条街时只需挂一次布。这张巨型喷绘布,就是 纹理图集(Texture Atlas)。

为什么图集能省 Draw Call?因为材质切换是 GPU 最贵的状态切换之一,而纹理引用是材质定义的一部分。多张小纹理打包成一张大纹理后,原本“材质不同”的网格现在引用同一张纹理,材质就“相同”了,合批条件被满足。代价是要做 UV 重映射:把原来指向小块纹理的 UV 坐标,换算成指向大图中对应区块的坐标。

图集不是万能药:大图占显存、mipmap 可能渗色、还要留 padding。但为了“同车同材质”这个合批前提,把这 20 块招牌印到一张布上,是性价比极高的一招。小葵的夜市一条街,从此一帧一趟车。

(u′,v′)=(u+jC,v+iR)\displaystyle (u', v') = \left( \frac{u + j}{C}, \frac{v + i}{R} \right)
这个公式是把小纹理的 UV 坐标映射进大图:把图集看成 \(R\) 行 \(\times C\) 列的网格,第 \(i\) 行第 \(j\) 列的小块纹理,其内部坐标 \(u,v\) 需要先缩放到 \(1/C\)、\(1/R\),再平移到对应区块。
  1. 原始 UV 坐标 \((u, v)\)物体在小纹理上的采样坐标,范围是 0 到 1,是“招牌布上的具体位置”。
    u,v∈[0,1]\displaystyle u, v \in [0, 1]
  2. 行列数 \(C, R\)图集把大图划分成 \(R\) 行、\(C\) 列,每个小格对应一张原来的小纹理。
    C×R=小纹理总数\displaystyle C \times R = \text{小纹理总数}
  3. 区块索引 \((i, j)\)当前小块纹理位于第 \(i\) 行、第 \(j\) 列,它决定平移量,相当于“这块招牌挂在大布的哪个位置”。
    0≤i<R,0≤j<C\displaystyle 0 \le i < R,\quad 0 \le j < C
  4. 映射后的坐标 \((u', v')\)新的 UV 坐标,GPU 用它在整张大图上采样,材质得以统一,合批随之成立。
    (u′,v′)=(u+jC,v+iR)\displaystyle (u', v') = \left( \frac{u + j}{C}, \frac{v + i}{R} \right)

工程实践:先看行车记录仪,再动手装箱

经历了几轮折腾,小葵学乖了:动手装箱前,先看行车记录仪。Profiler 会告诉她:到底是调度室忙(CPU 瓶颈),还是大黄在路上堵(GPU 瓶颈)?如果 GPU 已经 95% 满载,合批救不了帧率,得先减面或降分辨率;如果 CPU 的 Render Thread 时间爆表、Draw Call 计数高得吓人,才轮到合批发威。

优化的优先级有讲究:第一优先永远是“不画看不见的东西”——视锥剔除和遮挡剔除能让你白赚 30%~60% 的性能;然后是 LOD,远处的物体换轻量模型;最后才轮到合批。合批内部也有顺序:静态合批最划算,GPU 实例化次之,动态合批因为每帧有拼箱成本要放最后。渲染队列会按材质排序,进一步减少状态切换。

移动端尤其要小心:小卡车的油箱小(带宽与功耗有限),频繁发车和过度合批导致的内存上涨都可能翻车。小葵的最终工作流是:先剔除,再 LOD,然后静态合批带头、实例化殿后、动态合批压轴——这台“先计算后装箱”的铁律,让《山谷小镇》在新机型上稳稳回到了 60 帧。

Nvisible=Ntotal×Pvisible\displaystyle N_{\text{visible}} = N_{\text{total}} \times P_{\text{visible}}
这个公式是“剔除收益”的数学表达:真正需要绘制的物体数等于总数乘以可见比例。先把可见比例降下来,再谈合批,能让合批的集装箱装得更集中、更高效。
  1. 场景物体总数 \(N_{\text{total}}\)关卡里摆的所有物体,无论看得到看不到,它们都挂在渲染列表里等待被安排。
    Ntotal=场景全部物体\displaystyle N_{\text{total}} = \text{场景全部物体}
  2. 可见比例 \(P_{\text{visible}}\)经过视锥剔除、遮挡剔除后仍然可能出现在屏幕上的物体占比,通常在 30%~70% 之间。
    0<Pvisible<1\displaystyle 0 < P_{\text{visible}} < 1
  3. 可见物体数 \(N_{\text{visible}}\)真正需要进入渲染管线的数量,是 Draw Call 的上限来源,先砍它等于免费砍掉大量发车。
    Nvisible≤Ntotal\displaystyle N_{\text{visible}} \le N_{\text{total}}

问题排查:合批失败与收益评估

上线第二天,测试同学报 bug:“某个街区合批后反而更卡!”小葵打开 Frame Debugger 一看,集装箱被拆得七零八落——原来里面混了几件“特殊包装”的包裹。合批条件有多挑剔?她踩过的坑能写一页纸:两个物体哪怕 Shader 相同,只要材质参数不同(颜色、贴图引用),就是不同材质,不能同车;光照模式、阴影设置、顶点属性格式不一致,引擎也会拒绝合批。

动态合批还有自己的陷阱:它是“每帧现拼”,拼箱本身的 CPU 成本可能超过省下的发车成本。当物体数量大、顶点多、变换频繁时,动态合批往往得不偿失。遇到“合批后更卡”,优先检查三件事:材质是不是同一罐漆、光照模式是否统一、顶点格式是否兼容;然后用 Frame Debugger 逐帧确认合批是否真的生效。

评估一次优化,不能只看 Draw Call 数字这一项指标:用加速比衡量收益,同时盯着内存增量和剔除粒度。合批是工程权衡的艺术,不是把数字压到最小就赢了——小葵最终在“发车次数、仓库租金、掉头灵活性”之间找到了平衡,山谷小镇的快递网络从此高效运转。

Rspeedup=NbeforeNafter\displaystyle R_{\text{speedup}} = \frac{N_{\text{before}}}{N_{\text{after}}}
这个公式是合批收益的“体检指标”:优化前的 Draw Call 数量除以优化后的数量。加速比是 4 还是 1.2,直接决定这轮优化值不值得。
  1. 优化前调用数 \(N_{\text{before}}\)合批改造前每帧的 Draw Call 数量,是“没装箱时的发车次数”。
    Nbefore=改造前 Draw Call\displaystyle N_{\text{before}} = \text{改造前 Draw Call}
  2. 优化后调用数 \(N_{\text{after}}\)合批改造后每帧的 Draw Call 数量,是“装箱后的发车次数”。
    Nafter=改造后 Draw Call\displaystyle N_{\text{after}} = \text{改造后 Draw Call}
  3. 加速比 \(R_{\text{speedup}}\)两者相除得到收益倍数。它必须配合内存增量一起看:加速比高但内存翻三倍,未必划算。
    Rspeedup=NbeforeNafter\displaystyle R_{\text{speedup}} = \frac{N_{\text{before}}}{N_{\text{after}}}

AI 应用场景

场景1:开放世界游戏的海量植被渲染

阶段:AI 内容生成后的渲染落地

解决的问题:AI 生成工具一夜之间种了 10 万棵树、5 万丛花。如果每棵一个 Draw Call,CPU 直接瘫痪——这正是“GPU 很闲、CPU 冒烟”的 CPU-bound 噩梦。用静态合批处理不动的山体巨石,用 GPU 实例化处理几十万同种树木,用纹理图集统一不同植被的材质,才能让 AI 生成的美术资源跑得动。

具体案例:某开放世界项目中,10 万棵树木先经过视锥剔除与 LOD 过滤到 2 万棵可见,再按树种分组做 GPU Instancing,最终每帧 Draw Call 从几十万降到两百以内,主机端稳定 30 帧、PC 端稳定 60 帧。

场景2:数字孪生城市的批量设备可视化

阶段:数字孪生数据驱动的实时可视化

解决的问题:城市级 BIM 模型包含上百万个构件:路灯、井盖、消火栓、门窗。传统方式一个构件一次 Draw Call,浏览器端直接卡死。把重复构件按类型做静态合批,把动态点位用实例化渲染,能支撑城市级场景的流畅漫游。

具体案例:某智慧园区项目把 3.6 万个路灯全部静态合批为 12 个批次,把 8000 个智能井盖用实例化渲染,整体帧率从 8fps 提升到 45fps,同时显存占用只增加了 15%。

场景3:自动驾驶仿真环境的批量渲染

阶段:AI 仿真训练数据合成

解决的问题:自动驾驶仿真一次要渲染上百辆同款车、上千个行人,还要生成多视角数据。逐物体绘制导致仿真吞吐量极低,数据生成速度拖慢模型训练。GPU Instancing 让同型号车辆、同动作行人共享网格,大幅提升一帧内的有效物体数。

具体案例:某仿真平台用 GPU Instancing + 纹理图集将同场景车辆行人 Draw Call 减少 78%,单节点仿真吞吐量从每天 2000 帧提升到 6000 帧,合成数据成本显著下降。

Python 演示

python63 行
# -*- coding: utf-8 -*-# 模拟 Draw Call 批处理: 按材质分组 + 顶点上限装箱# 场景是 2000 个散落在街边的小物件, 分属 4 种材质  class RenderObject:    # 一个待渲染物体: 名称 / 材质 ID / 顶点数    def __init__(self, name, material_id, vertex_count):        self.name = name        self.material_id = material_id        self.vertex_count = vertex_count  class BatchPlanner:    # 批处理规划器: 统计合批前后的 Draw Call 数量与 CPU 耗时    def __init__(self, call_cost=0.008, switch_cost=0.015, budget_ms=16.7):        self.call_cost = call_cost       # 单次 Draw Call 固定开销(ms)        self.switch_cost = switch_cost   # 每次材质/状态切换开销(ms)        self.budget_ms = budget_ms       # 一帧预算: 16.7ms     def count_batches(self, objects, max_vertices=900):        # 先按材质分组, 再对每组按顶点上限拆批        groups = {}        for obj in objects:            groups.setdefault(obj.material_id, []).append(obj)        total_batches = 0        for material_id in groups:            batch_vertices = 0            for obj in groups[material_id]:                if batch_vertices + obj.vertex_count > max_vertices:                    total_batches += 1                    batch_vertices = 0                batch_vertices += obj.vertex_count            if batch_vertices > 0:                total_batches += 1        return total_batches     def estimate_cpu_ms(self, batch_count):        # 每次发车都要付一次调用费 + 一次状态切换费        return batch_count * (self.call_cost + self.switch_cost)  def main():    # 街道场景: 2000 个 240 顶点的小物件, 材质 ID 循环为 0/1/2/3    objects = [RenderObject("obj_%d" % i, i % 4, 240) for i in range(2000)]    planner = BatchPlanner()     before = len(objects)  # 不合批: 一个物体就是一个 Draw Call    after = planner.count_batches(objects)  # 合批后    t_before = planner.estimate_cpu_ms(before)    t_after = planner.estimate_cpu_ms(after)     print("before batching: draws = %d, cpu = %.2f ms" % (before, t_before))    print("after  batching: draws = %d, cpu = %.2f ms" % (after, t_after))    print("speedup = %.1f x" % (float(before) / after))    if t_after < planner.budget_ms:        print("result: within %.1f ms frame budget, smooth" % planner.budget_ms)    else:        print("result: still over budget, need more culling or batching")  if __name__ == "__main__":    main()
目的:模拟一个 2000 件小物件的街道场景,先按材质分组,再按 900 顶点上限拆批,对比合批前后的 Draw Call 数量与 CPU 帧耗时,直观验证“集装箱革命”的收益。
  1. objects = [RenderObject("obj_%d" % i, i % 4, 240) for i in range(2000)]生成 2000 个小物件,材质 ID 在 0~3 间循环,模拟四种不同“车厢”。
  2. groups.setdefault(obj.material_id, []).append(obj)按材质 ID 分组,材质不同不能同车,这是合批的第一铁律。
  3. if batch_vertices + obj.vertex_count > max_vertices:顶点数超过 900 就“另开一箱”,模拟动态合批的顶点上限约束。
  4. before = len(objects)合批前每个物体一次 Draw Call,2000 个物体就是 2000 次发车。
  5. float(before) / after计算合批加速比,衡量这轮优化的收益倍数。

常见误区

误区以为 Draw Call 越小性能越好,于是把所有模型都塞进一个大网格里,Draw Call 变成 1,结果帧率反而没提升,内存还爆了。

正解合批是“用空间换时间”的工程权衡。静态合批让内存近似翻倍、让剔除粒度变大——只要庞大网格有一部分可见,整块都要进渲染管线。真正的目标是“让 CPU 不再是瓶颈”:把 Draw Call 从 5000 压到 200,CPU 已经轻松留在 16.7ms 预算内,再往下压就是负收益了。

为何常见:新手容易把单一指标当成排行榜分数来比,忽略了内存、剔除粒度和动态合批成本这些同样昂贵的代价。

误区觉得引擎勾上 Dynamic Batching 选项,就会自动魔法般合批,CPU 躺着省力。

正解动态合批每帧都要把所有参与物体的顶点变换到世界空间、写入临时缓冲、再提交 GPU,这本身就是 CPU 开销。当参与物体数量大、顶点多时,拼箱成本可能超过省下的发车成本,出现“合批后更卡”的怪象。这正是小葵在夜市踩过的坑。

为何常见:“动态”这个前缀听起来像自动化魔法,加上引擎默认勾选,让人误以为它是免费的。

误区明明用了同一个 Shader 材质,引擎却不合批,怀疑是引擎渲染管线的 bug。

正解合批条件比想象中严苛:哪怕 Shader 相同,只要材质参数(颜色、贴图引用)不同,就是“不同材质”;光照模式、阴影设置、顶点属性格式不一致,同样会阻断合批。Frame Debugger 的“为什么合不批”面板会逐条列出原因,逐项核对比甩锅给引擎高效得多。

为何常见:渲染引擎的合批决策是黑盒,初学者看不到内部判定逻辑,自然容易归因于 bug。

误区觉得 Instancing 就是“种树种草”用的,城市里建筑各不相同,用不上。

正解实例化的本质是“同一份网格 + 不同的变换/属性”。大量相同的路灯、井盖、门窗、桌椅、人群、道具副本,都是天然的实例化对象。建筑外形不同,但同类门窗、栏杆、玻璃幕墙单元往往相同,照样能用。变体差异用每实例数据(矩阵、颜色、缩放)表达即可。

为何常见:教程和案例最爱拿草和树举例,形成“实例化 = 植被优化”的刻板印象。

面试题

Q1. 在 3D 渲染管线中,Draw Call 指的是什么?

单选题 · easy

  • A.GPU 向 CPU 回传渲染结果的信号
  • B.CPU 向 GPU 发出的单次绘制指令← 正确答案
  • C.显卡厂商提供的硬件型号代号
  • D.纹理在显存中的存储格式名称

答案:B。Draw Call 是 CPU 通过图形 API 发给 GPU 的“画这个物体”命令,包含网格、材质、变换等关键信息。GPU 向 CPU 回传的是读取结果,与指令定义无关。B 正确,其余选项描述的都是别的概念。

Q2. 某场景 GPU 利用率只有 35%,CPU 帧耗时接近预算上限,最可能的原因是?

单选题 · easy

  • A.显卡性能太弱导致渲染过慢
  • B.每帧 Draw Call 过多致 CPU 瓶颈← 正确答案
  • C.屏幕分辨率设置得过高导致卡顿
  • D.显存容量不足导致纹理数据丢失

答案:B。GPU 闲、CPU 忙是典型的 CPU-bound 特征,正是帧耗时公式里 N_draw 过大造成的。显卡弱会表现为 GPU 满载,分辨率过高会拉高 GPU/填充率压力,显存不足则会出现纹理丢失而非单纯掉帧。

Q3. 60 帧每秒渲染目标下,单帧可用的总预算约为?

单选题 · easy

  • A.16.7 毫秒← 正确答案
  • B.33.3 毫秒
  • C.60.0 毫秒
  • D.100.0 毫秒

答案:A。1 秒等于 1000 毫秒,除以 60 帧约等于 16.7ms。这是 CPU 指令准备与 GPU 渲染共享的总预算,一旦超出,帧率就会跌破 60,表现为肉眼可见的卡顿。

Q4. 关于静态批处理,下列说法正确的是?

单选题 · mid

  • A.每帧重新合并所有网格且不增加内存
  • B.适合频繁移动的物体且成本极低
  • C.静态网格提前合并,减调用但增内存← 正确答案
  • D.合批后每个物体仍可独立视锥剔除

答案:C。静态合批把不动的网格在加载期焊成大网格,Draw Call 下降但顶点数据被复制进新缓冲,内存上升;合并后只能按整体剔除,D 与事实相反。A/B 描述的是动态合批的特征,且动态合批也不免费。

Q5. Unity 中动态合批对单批顶点数的主流限制约为?

单选题 · mid

  • A.上限约三百顶点
  • B.上限约九百顶点← 正确答案
  • C.上限六万五顶点
  • D.完全没有上限

答案:B。动态合批要求批次内总顶点数不超过约 900 个(不同平台略有差异),超过则另起一批。65536 是单个网格索引的经典上限,与动态合批的每帧合并约束无关。

Q6. 以下哪些情况会导致合批失败(无法合并)?

多选题 · hard

  • A.使用了不同的材质实例← 正确答案
  • B.光照与阴影模式不一致← 正确答案
  • C.顶点属性格式不相同← 正确答案
  • D.场景中位置完全相同

答案:ABC。材质实例不同(即使 Shader 相同,参数不同也算不同材质)、光照/阴影模式不同、顶点格式不同,都会让 GPU 无法用同一渲染状态绘制,从而阻断合批。位置相同不影响合批条件,反而可能触发其他优化。

Q7. 纹理图集降低 Draw Call 的核心原因是?

单选题 · mid

  • A.让纹理占用的显存变小
  • B.让多网格共用材质满足合批← 正确答案
  • C.让 GPU 采样纹理速度翻倍
  • D.让 CPU 不再处理纹理数据

答案:B。图集把多张小纹理装进一张大纹理,多个网格引用同一张图就拥有了“同一材质”,从而满足合批前提,减少纹理切换与 Draw Call。图集本身并不保证显存变小,也不直接加速采样。

Q8. 关于 GPU Instancing,下列说法正确的是?

多选题 · hard

  • A.适合绘制大量相同网格的物体← 正确答案
  • B.一次调用可绘制多个不同变换实例← 正确答案
  • C.每实例的顶点数据须单独保存
  • D.与动态合批相比 CPU 开销更低← 正确答案

答案:ABD。Instancing 复用同一份顶点数据,靠每实例数据(变换矩阵、颜色等)区分副本,所以 C 错误;它不需要每帧在 CPU 端重建顶点缓冲,CPU 开销通常显著低于动态合批。A、B、D 都是实例化的典型特征。

Q9. 静态合批后内存显著上升的根本原因是?

单选题 · hard

  • A.纹理被复制到多个不同画布
  • B.顶点数据被复制进新的合并缓冲← 正确答案
  • C.帧缓冲的分辨率被自动提高
  • D.渲染状态缓存无法被释放

答案:B。合批把各小网格的顶点复制并拼接成新的大网格缓冲,原始数据仍保留,因此内存近似翻倍。纹理复制、帧缓冲分辨率、状态缓存都与静态合批没有直接关系。

Q10. 动态合批的局限性包括以下哪些?

多选题 · mid

  • A.批次内总顶点数存在硬上限← 正确答案
  • B.参与合批物体须用相同材质← 正确答案
  • C.每帧合并本身会消耗 CPU← 正确答案
  • D.参与合批的物体必须静止

答案:ABC。动态合批专门服务“会动”的物体,D 刚好说反;顶点上限、同材质要求以及每帧拼箱的 CPU 成本,都是动态合批的真实局限。这也是它在移动端需要谨慎使用的原因。