4、CPU优化进阶:静态合批与动态合批、GPU Instancing的原理与使用、LOD(细节层次)技术

好,咱们继续往下聊。上一章我们把CPU优化的基础讲得差不多了,这一章咱们来点硬核的——静态合批、动态合批、GPU Instancing,还有LOD。这几个技术,说白了就是让CPU少干活,让GPU多干活。我做了这么多年优化,发现很多项目卡顿,其实不是GPU不行,而是CPU在Draw Call上累死了。

4.1 静态合批:一劳永逸的合并

静态合批,名字听着挺唬人,其实原理很简单。就是把那些不会移动、不会旋转、不会缩放的静态物体,在编辑阶段就合并成一个大的Mesh。这样运行时,CPU只需要发一次Draw Call,就能把整个场景里所有静态物体全画出来。

核心要点:静态合批只适用于标记为"Static"的游戏对象。一旦标记,这些物体在运行时就不能动了。

我在项目中遇到过这么个事:一个开放世界场景,光路灯就有2000多个。每个路灯都是一个单独的GameObject,Draw Call直接飙到3000多。后来我把所有路灯标记为Static,开启静态合批,Draw Call直接降到了200左右。效果立竿见影。

不过要注意,静态合批不是万能的。它有个明显的缺点——内存占用会增加。因为合批后的Mesh会包含所有子物体的顶点数据,哪怕这些顶点在空间上离得很远。我建议你只在物体密集的区域使用静态合批,比如城市街区、室内场景。

优点 缺点
大幅减少Draw Call 增加内存占用
运行时无额外CPU开销 物体不能移动
实现简单,只需勾选Static 合批后的Mesh无法单独剔除

4.2 动态合批:灵活的运行时合并

动态合批跟静态合批不一样。它是在运行时,由Unity自动把符合条件的动态物体合并到一起。说白了,就是CPU在每帧渲染前,临时把一些小Mesh拼成一个大Mesh,然后一次性提交给GPU。

嗯,这里要注意。动态合批的条件非常苛刻。我列几个关键点:

  • 顶点数不能超过900个(在OpenGL ES 2.0上是300个)
  • 材质必须完全相同
  • 不能使用镜像变换(比如Scale为负数)
  • 不能有光照贴图、反射探针等复杂属性

为什么会这么严格?因为动态合批是在CPU上做的,每帧都要重新计算合并后的顶点数据。如果顶点太多,CPU反而会忙不过来,得不偿失。我曾经在一个项目中,为了省Draw Call,把一堆小石头都开了动态合批。结果帧率反而下降了——因为每帧CPU都在忙着合并这些石头,根本没空干别的。

避坑指南:我曾经在粒子系统上开动态合批,结果发现粒子数量一多,CPU直接爆了。后来我才意识到,粒子系统本身就有自己的合批机制,不需要额外开动态合批。记住:动态合批只适用于小物体、少顶点的情况。

4.3 GPU Instancing:让GPU自己复制自己

GPU Instancing,这个技术我特别喜欢。它的思路跟合批完全不同。合批是把多个物体合并成一个Mesh,而Instancing是告诉GPU:"嘿,这里有一个Mesh,你帮我画1000次,每次的位置、颜色稍微变一变就行。"

说白了,就是CPU只发一次Draw Call,GPU自己循环画很多遍。这比合批更高效,因为CPU几乎不干活。

使用GPU Instancing的条件:

  • 使用支持Instancing的Shader(Unity标准Shader默认支持)
  • 材质必须相同
  • 每个实例可以通过MaterialPropertyBlock传递不同的属性(颜色、缩放等)
// 一个简单的GPU Instancing示例
void Update() {
    // 获取所有相同材质的物体
    Renderer[] renderers = FindObjectsOfType<Renderer>();
    List<Matrix4x4> matrices = new List<Matrix4x4>();
    List<MaterialPropertyBlock> props = new List<MaterialPropertyBlock>();

    foreach (var renderer in renderers) {
        if (renderer.sharedMaterial == myMaterial) {
            matrices.Add(renderer.localToWorldMatrix);
            MaterialPropertyBlock block = new MaterialPropertyBlock();
            block.SetColor("_Color", Color.red);
            props.Add(block);
        }
    }

    // 使用Graphics.DrawMeshInstanced批量绘制
    Graphics.DrawMeshInstanced(myMesh, 0, myMaterial, matrices, props);
}

我个人习惯在植被、石块、装饰物这类重复度高的物体上使用Instancing。比如一片森林,几千棵树,用Instancing的话,Draw Call可能只有几十个。效果非常明显。

小技巧:如果你发现Instancing没生效,先检查一下Shader。有些自定义Shader没有开启Instancing支持。在Shader代码里加上 #pragma multi_compile_instancing 就能解决。

4.4 LOD(细节层次)技术:远处的物体别那么精细

LOD,全称Level of Detail。这个技术我愿称之为"性价比之王"。它的原理很简单:物体离相机越远,我们就用越粗糙的模型来渲染。因为远处的物体本来就看不清楚细节,何必浪费性能去渲染高精度模型呢?

LOD通常分3-4级:

  • LOD0:最高精度,近距离使用
  • LOD1:中等精度,中距离使用
  • LOD2:低精度,远距离使用
  • LOD3:极低精度或完全不渲染(Culled)

我在项目中遇到过最典型的案例:一个大型城市场景,建筑模型的面数高达10万。如果不做LOD,整个场景的顶点数轻松破亿。后来我们给每个建筑做了3级LOD,LOD0是10万面,LOD1是3万面,LOD2是5000面。最终性能提升了4倍,而且玩家根本看不出区别。

你想想看,一个建筑在500米外,你连窗户都看不清,何必保留10万个顶点?这就是LOD的精髓——把性能花在刀刃上。

LOD级别 距离范围 面数比例 性能消耗
LOD0 0-20米 100%
LOD1 20-50米 50%
LOD2 50-100米 20%
Culled 100米以上 0%

避坑指南:我曾经在LOD切换时没处理好,导致玩家在跑动时,远处的建筑突然"跳"出来,非常突兀。后来我用了LOD Group组件里的Fade Transition模式,让LOD切换时有0.5秒的淡入淡出效果,视觉上就平滑多了。记住:LOD切换一定要平滑,否则玩家会感觉画面在"闪烁"。

4.5 如何选择:合批 vs Instancing vs LOD

这三个技术不是互斥的,它们可以组合使用。我一般这样决策:

  • 静态物体、数量多、材质相同 → 静态合批 + LOD
  • 动态物体、数量多、材质相同、顶点少 → 动态合批
  • 重复度极高、材质相同、需要独立属性 → GPU Instancing + LOD
  • 大型场景、物体面数高 → 优先做LOD,再考虑合批

说白了,没有银弹。每个项目的情况都不一样,你需要根据实际场景来测试。我个人的习惯是:先做LOD,因为它的性价比最高。然后看Draw Call,如果还是太高,再考虑合批或Instancing。

嗯,这一章的内容就到这里。下一章我们会聊CPU优化的另一个大招——遮挡剔除和视锥体剔除。这两个技术能帮你把看不见的物体直接扔掉,性能提升非常可观。咱们下章见。