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时,发现:
-
它们都在移动。
-
它们都使用同一个材质。
-
这个材质明确表示支持GPU Instancing!
-
-
于是,Unity的渲染管线自动触发了一个“隐藏”的、基于Instancing的合批路径。它不再使用传统的、CPU开销大的动态批处理,而是自动为你收集所有使用该材质的物体的变换矩阵,然后打包成几个DrawMeshInstanced的指令发送给GPU。
-
这个过程是Unity引擎在C++底层自动完成的,效率远高于在C#里手动收集。
结论:
-
Draw Call 大幅减少: 因为Unity自动帮你做了DrawMeshInstanced的工作。
-
但性能依然不如手动方案: 为什么?因为 Update.ScriptRunBehaviourUpdate 的瓶颈依然存在!CPU仍然需要调用10000次UnitMover.Update(),这部分的开销是自动Instancing合批无法解决的。
扩展:如何让每个单位颜色不同