> 技术文档 > Unity性能优化-万人同屏(GPU Instancing)_untiy spine无法使用gpu instancing

Unity性能优化-万人同屏(GPU Instancing)_untiy spine无法使用gpu instancing

本文仅作为个人学习与思考的记录之用,内容可能存在理解偏差或不完善之处。欢迎批评指正,请读者自行甄别。

在场景中创建一个简单Cube,并为它创建一个材质(material),随便为它选取一个颜色,将这个Cube做成prefab.

一:暴力法

这是最符合直觉但性能最差的方法,创建10000个独立的GameObject,并且每个都带有一个用于移动的脚本。

1.创建移动脚本

using System.Collections;using System.Collections.Generic;using UnityEngine;public class UnitMover : MonoBehaviour{ private float speed; private Vector3 direction; void Start() { speed = Random.Range(1, 3); transform.position = new Vector3(Random.Range(-50, 50), 0, Random.Range(-50, 50)); direction = new Vector3(Random.Range(-1, 1), 0, Random.Range(-1, 1)).normalized; } void Update() { transform.position += direction * speed * Time.deltaTime; if (transform.position.x > 50 || transform.position.x  50 || transform.position.z < -50) { direction.z *= -1; } }}

2.创建管理器

在场景中创建一个空GameObject,把这个脚本挂上去

using System.Collections;using System.Collections.Generic;using UnityEngine;/// /// 最高只有15帧,明显卡顿,Scripts → Update.ScriptRunBehaviourUpdate 占用最高/// public class Spawner : MonoBehaviour{ public GameObject unitPrefab; public int numberOfUnits = 10000; private void Start() { for (int i = 0; i < numberOfUnits; i++) { GameObject unit = Instantiate(unitPrefab); unit.AddComponent();  } }}

3.运行与分析

将prefab拖到Spawner的unitPrefab字段上,运行

4.结果:帧率非常低,有明显卡顿,层级面板爆炸。

打开性能分析器会看到PlayerLoop下的Update.ScriptRunBehaviourUpdate占用了大量时间,因为Unity没帧都要调用10000次UnityMove.Update()。

Batches数量也非常高,每个物体都需要单独一次DrawCall。

性能瓶颈

1.CPU瓶颈

过多的Update调用,10000脚本的update开销巨大,

过多的DrawCall,CPU需要准备和发送10000个绘制指令给GPU,负担太重。

2.内存瓶颈

10000个GameObject及其组件占用了大量的内存

二:GPU Instancing

这种方法的核心是逻辑我来管,渲染GPU一次性画完,我们不再创建10000个物体,而是在一个脚本里计算好所有单位的位置,然后调用Graphics.DrawMeshInstanced,告诉GPU,用这个模型和这个材质,在这些位置上,一次性给我画出10000出来。

1.打开之前为Cube创建的材质,勾选Enable GPU Instancing。必须开启Enable GPU Instancing 才能使用 DrawMeshInstanced

2.创建新的管理器脚本

using System.Collections;using System.Collections.Generic;using UnityEngine;/// /// GPU Instancing 方法(最优)/// GPU实例化,可以在GPU上处理多个相同的对象,减少CPU负担。/// public class Spawner_Instanced : MonoBehaviour{ public int count = 10000; public GameObject unitPrefab; public Mesh unitMesh; // 单位的模型 public Material unitMaterial; // 开启了GPU Instancing的材质 // === 数据层 === // 不再使用Transform组件,而是自己存储位置、方向和速度 private List matrices; //存储所有单位的变换矩阵(包含位置、旋转、缩放) private List directions; private List speeds; ///  /// 为了避免在update中频繁分配内存,使用数组来存储矩阵数据 ///  private Matrix4x4[] matrixArray; private void Start() { var prefabRenderer = unitPrefab.GetComponent(); var prefabFilter = unitPrefab.GetComponent(); unitMesh = prefabFilter.sharedMesh; unitMaterial = prefabRenderer.sharedMaterial; // 将位置、旋转、缩放组合成变换矩阵 matrices = new List(count); directions = new List(count); speeds = new List(count); matrixArray = new Matrix4x4[count]; for (int i = 0; i < count; i++) { Vector3 position = new Vector3(Random.Range(-50f, 50f), 0, Random.Range(-50f, 50f)); Quaternion rotation = Quaternion.Euler(0, Random.Range(0f, 360f), 0); Vector3 scale = Vector3.one; // Matrix4x4 包含了位置、旋转、缩放信息 matrices.Add(Matrix4x4.TRS(position, rotation, scale)); directions.Add(new Vector3(Random.Range(-1,1),0,Random.Range(-1,1)).normalized); speeds.Add(Random.Range(1f, 3f)); } } private void Update() { // 在一个循环中更新所有单位的数据,没有MonoBehaviour.Update()的开销 for (int i = 0; i < count; i++) { // 1. 从Matrix中分解出位置 Vector3 position = matrices[i].GetColumn(3); //2. 更新位置 position += directions[i] * speeds[i] * Time.deltaTime; if(position.x  50f) { // 如果超出范围,掉转方向 var dir = directions[i]; dir.x *= -1; directions[i] = dir; } if(position.z  50f) { var dir = directions[i]; dir.z *= -1; directions[i] = dir; } // 3. 将新位置和其他信息组合回Matrix matrices[i] = Matrix4x4.TRS(position, matrices[i].rotation, Vector3.one); } // --- 渲染 --- // Graphics.DrawMeshInstanced 对数量有限制,一次最多1023个 // 所以需要分批次绘制 int batchCount = Mathf.CeilToInt((float)count / 1023); for (int i = 0; i < batchCount; i++) { int startIndex = i * 1023; int numToDraw = Mathf.Min(1023, count - startIndex); // 从List转换到Array段 matrices.CopyTo(startIndex, matrixArray, 0, numToDraw); // 调用新的DrawMeshInstanced重载 Graphics.DrawMeshInstanced( unitMesh, 0, // submesh index unitMaterial, matrixArray, // 数组版本,不是List numToDraw ); } }}

3.在场景中找到Cube预制体,拖入层级窗口中。运行

结果:帧率大幅提升,层级面板干净

再看性能分析器,Update.ScriptRunBehaviourUpdate的开销小了很多,

DrawCall数量急剧下降。

这时再回过去看,材质已经勾选了Enable GPU Instancing,用第一种方法DrawCall反而更少,这是因为触发了unity的自动渲染技术,动态批处理。那个本应性能很差的暴力 Instantiate 方法,其 Draw Call 还变少了。

1.静态批处理

  • 条件: 适用于场景中完全静止的物体(在Inspector中标记为Static)。

  • 原理: 在游戏开始前,Unity会将所有标记为Static且使用相同材质的物体合并成一个或几个巨大的网格(Mesh)。运行时,只需要一个Draw Call就能画出这个大网格。

  • 优点: 效果极好,运行时几乎没有CPU开销

  • 缺点: 占用更多内存,且物体不能移动。

2.动态批处理

  • 条件: 适用于场景中正在移动的、顶点数很少的物体(通常小于900个顶点属性,约300个顶点)。

  • 原理: 在每一帧,CPU会遍历场景中所有符合条件的移动物体,如果它们使用相同的材质,CPU就会将它们的顶点数据合并到一个缓冲区里,然后用一个Draw Call发给GPU。

  • 优点: 对移动物体有效,无需手动操作。

  • 缺点: CPU每帧都要做合并工作,有一定开销。对模型顶点数要求苛刻,稍微复杂点的模型就失效了。

上面遇到的情况是 Enable GPU Instancing 影响了动态批处理。暴力实例化方法创建了10000个移动的Cube。Cube的顶点数非常少,完全符合动态批处理的条件。

Case 1: 材质未勾选 Enable GPU Instancing

  • Unity尝试对这10000个移动的Cube进行动态批处理

  • 但是,动态批处理本身有其CPU开销和内部限制。一次动态批处理能合并的物体数量是有限的。

  • 面对10000个物体,CPU的动态批处理能力很快就达到了上限。它可能会成功合并几百个,但剩下的几千个依然需要单独的Draw Call。

  • 同时,10000个UnitMover.Update()脚本的开销是始终存在的。

  • 结果: Draw Call数量依然很高,CPU脚本开销巨大,性能很差(15 FPS)。

Case 2: 材质勾选了 Enable GPU Instancing

  • 这是关键! 当你为一个材质勾选 Enable GPU Instancing 时,你等于在告诉Unity的渲染管线:“嘿,这个材质是支持Instancing的,请优先考虑使用Instancing的方式来批处理使用这个材质的物体!”

  • Unity的渲染循环在遍历这10000个Cube时,发现:

    1. 它们都在移动。

    2. 它们都使用同一个材质。

    3. 这个材质明确表示支持GPU Instancing!

  • 于是,Unity的渲染管线自动触发了一个“隐藏”的、基于Instancing的合批路径。它不再使用传统的、CPU开销大的动态批处理,而是自动为你收集所有使用该材质的物体的变换矩阵,然后打包成几个DrawMeshInstanced的指令发送给GPU。

  • 这个过程是Unity引擎在C++底层自动完成的,效率远高于在C#里手动收集。

结论:

  • Draw Call 大幅减少: 因为Unity自动帮你做了DrawMeshInstanced的工作。

  • 但性能依然不如手动方案: 为什么?因为 Update.ScriptRunBehaviourUpdate 的瓶颈依然存在!CPU仍然需要调用10000次UnitMover.Update(),这部分的开销是自动Instancing合批无法解决的。

扩展:如何让每个单位颜色不同