Unity GPU Instancing 合批失败问题排查与解决
Unity GPU Instancing 合批失败问题排查与解决
事情的起因是我的一个项目需要在场景中使用大量的GPUSkinning动画,在将项目打包后真机运行时发现,场景中GPUSkinning越多帧率越低。虽然项目场景中通常会有1~2k个GPUSkinning,但是这些实例都只是由11种不同的GPUSkinning克隆出来实例化的。同一种GPUSkinning使用相同的Mesh、Material,理应自动合批,但经过检查发现场景运行时的batches约等于乘客数量,由此断定合批失败,每个乘客一个draw call ,项目具有严重的性能问题。于是有了本文问题排查与解决过程。
关于合批检查
有两种方法可以查看当前场景的合批情况
- 点击Game视图右上角Stats按钮打开Statistics界面,其中的Batches就是批数量,通常与draw call数量相等

- 打开Frame Debugger窗口,查看Camera.Render下的Drawing事件,有合批的事件名称会有(instanced)标记。在右侧详情界面也可以看到当前事件的draw call数量和实例数量。

问题排查
项目种使用了一个GPUSkinning插件,也是个比较广为人知的插件了,这里贴出GitHub链接
GitHub - chengkehan/GPUSkinning · GitHub
通过查看GitHub仓库的介绍我断定该插件转换的GPUSkinning确实应该是能够自动合批的,所以首先我检查了插件设置:
检查材质:
该GPUSkinning插件转换后产生四个依赖产物,我们点击选中其中的材质球,检查材质是否开启Enable GPU Instancing。
这个选项决定这个材质是否编译/允许使用 instancing 变体——只有开启了它,这个材质才有可能参与 GPU Instancing;不开启则一定不会合批。

发现没有开启Enable GPU Instancing,将项目中用到的11种材质全部开启Enable GPU Instancing后再次尝试。发现没有任何效果,仍然几乎每个乘客单独一个draw call

[!NOTE]
后来经过对插件深入了解发现,该插件会在运行时自动开启Enable GPU Instancing,材质是否单独设置没有影响。
检查动画:
此时,我想到我开启了Individual Difference Enabled这个功能,这是GPUSkinning插件提供的一个让动画差异播放的功能,打开之后不同实例的相同动画能够从不同的帧开始播放,实现每个实例差异化,不会整齐化一。
我推测会不会是因为每个乘客的动画差异导致了无法合批,于是我打开GPUSkinning转换后的so中,将所有动画的Individual Difference Enabled全部关闭,再次尝试。合批成功。


但是在插件的GitHub仓库中看到,这个动画差异化功能有动图演示,而且该插件2017年就停止更新,翻看Issues也没有找到相关问题的反馈,我推测即使是开启了这个动画差异功能,正常情况下也是能够合批的。这个Individual Difference Enabled功能不是导致此次问题的根因。
后面经过深入探索发现,打开Individual Difference Enabled后插件会在动画切换时进行一次随机帧偏移,然后传给shader的一个instanced属性,理论上不会造成合批失败。(标记为Instanced的属性被写入不会影响合批)。所以这不是根因。
检查shader
然后进行更加深入的检查,查看Shader源码。本项目GPUSkinning使用的shader是美术部门制作的自定义shader,检查后发现了一个名为_OutLineColor的属性,本项目中没有使用描边相关的效果,这个属性没有用到,应该是别的项目遗留下来的。
使用AI对这个属性进行检索,看看谁在写这个属性,检查发现GPUSkinning插件也被修改过,GPUSkinningSurface.cginc中有新增属性_OutLineColor,没有使用宏标记为Instanced,并且在插件脚本GPUSkinningPlayerResources.cs中每帧mpb写入,这才是导致无法合批的根本原因。

问题解决
解决方案有两种:
- 删除这个_OutLineColor属性的每帧mpb写入
- 在程序中手动收集实例合批上报
我比较倾向于尽量不要随意改动别人制作的东西,所以我采用第二种方案。在程序中手动收集实例合批上报。
方案1也比较简单,这里就不多解释了。这里着重说明一下方案2的实现。
GPU Instancing原理
为了实现方案2,需要首先了解GPU Instancing的原理。
什么是 GPU Instancing
同一个 mesh + 同一个 material,一次性把多个实例(每个有自己的变换矩阵和少量 per-instance 数据)提交给 GPU,由 GPU 用 unity_InstanceID 在 vertex shader 里区分每个实例,用 1 个 draw call 画出 N 个实例。
Unity 触发 instancing 的两种路径
- 默认渲染路径:多个
MeshRenderer共享同一sharedMaterial+ 同一 mesh + 无 MaterialPropertyBlock(或 MPB 只写 instancing prop),Unity 自动 batch。 - 手动提交路径:
Graphics.DrawMeshInstanced/Graphics.RenderMeshInstanced,C# 侧把 N 个Matrix4x4+ per-instance 数据一次性提交。
本文要实现的就是手动提交路径——C# 自己接管提交,使Unity 的 MPB 合批判断不再参与。
per-instance 数据怎么传到 shader
- shader 用
UNITY_DEFINE_INSTANCED_PROP(type, name)声明 per-instance 属性 - C# 用
MaterialPropertyBlock.SetVectorArray(name, Vector4[])写入数组(每实例一个 Vector4) - shader 用
UNITY_ACCESS_INSTANCED_PROP(arrName, name)读取,Unity 自动用unity_InstanceID索引
关键:instancing buffer 里每个属性按 float4 对齐,即使声明 float2 也占 16 字节。SetVectorArray 每 instance 一个 Vector4,shader 端 float2 自动取 .xy。
单次提交上限
Graphics.RenderMeshInstanced 单次 ≤ 1023 实例。超过需循环拆批。
代码实现
首先我们需要准备一个结构体,记录每个实例本体渲染所需要的信息,在每个实例实例化时收集就可以了。为了防止反复遍历所有实例,我们加个缓存。
// 每个实例的本体渲染数据缓存(避免每帧 GetComponent)
private readonly List<PassengerRenderInfo> renderInfos = new List<PassengerRenderInfo>();
private struct PassengerRenderInfo
{
public GPUSkinningPlayerMono mono; // 可空(非 GPUSkinning 预制体)
public MeshRenderer bodyRenderer; // 乘客本体 MeshRenderer(要禁用以防双重渲染)
public MeshFilter bodyFilter; // 乘客本体 MeshFilter(取 sharedMesh)
public bool cullingConfigured; // 是否已对该 player 设过 CullingMode/LOD
}
EntityInstancedRenderer.cs
然后就是我们需要每帧对缓存的渲染信息进行分桶,然后一次性合批提交。我们新建一个脚本EntityInstancedRenderer.cs准备两个结构BucketKey和Bucket,用来存储分桶数据。
Matrices:决定每个实例“摆在哪”
- 类型为
List<Matrix4x4>,每个元素是一个实例的 TRS 变换矩阵(位置、旋转、缩放),由外部调用SubmitInstance(mesh, material, trs, ...)时传入。 - 提交阶段直接作为
Graphics.RenderMeshInstanced的matrices参数,Unity 依据它完成逐实例的实例化渲染——同一批里 1000 个实体可以在 1000 个不同的位置、以不同的姿态出现。
FrameSeg:决定每个实例“播哪一帧”
- 类型为
List<Vector4>,每个元素打包了该实例的两个动画参数:(frameIndex, pixelSegmentation, 0, 0):- frameIndex:GPU 蒙皮动画当前帧号,shader 用它去烘焙的骨骼矩阵纹理中采样,实现顶点动画;
- pixelSegmentation:与 shader 配合的像素分割辅助参数。
- 提交阶段通过
_mpb.SetVectorArray(FrameIndexSegID, ...)写入MaterialPropertyBlock,shader 端以UNITY_DEFINE_INSTANCED_PROP(float2, _GPUSkinning_FrameIndex_PixelSegmentation)接收,自动取.xy。
[!IMPORTANT]
因为Unity 的 instancing buffer 内部按 float4 对齐:如果按每实例 2 个 float 连续写入,相邻实例的数据在缓冲区里会整体错位,表现就是动画卡死或播放错乱。因此每实例占满 4 个 float——用
List<Vector4>+SetVectorArray,冗余的 z、w 分量填 0,shader 端声明为float2后自动截取.xy,数据就能严格对齐。
// 一个桶 = 一个 (mesh, material) 组合:同 mesh 同 material 的实例凑成一桶,一次性提交。
private struct BucketKey
{
public Mesh Mesh;
public Material Material;
}
private class Bucket
{
public readonly List<Matrix4x4> Matrices = new List<Matrix4x4>(MaxBatchSize);
public readonly List<Vector4> FrameSeg = new List<Vector4>(MaxBatchSize);
}
下一步,我们在脚本中封装三个方法供外部调用:
- 每帧收集前清空桶缓存
- 将一个实例分桶
- 遍历所有桶,分桶提交渲染
其中,第三步通过Graphics.RenderMeshInstanced(Unity提供的手动实例化渲染接口)方法在一次draw call里全部提交给GPU渲染。
/// 每帧收集前调用,清空所有桶。
public void BeginFrame()
{
foreach (var kv in _buckets)
{
kv.Value.Matrices.Clear();
kv.Value.FrameSeg.Clear();
}
}
/// 实例分桶:同 mesh 同 material 的实例自动归入同一桶。
public void SubmitInstance(Mesh mesh, Material material, Matrix4x4 trs,
float frameIndex, float pixelSegmentation)
{
if (mesh == null || material == null) return;
var key = new BucketKey { Mesh = mesh, Material = material };
if (!_buckets.TryGetValue(key, out Bucket bucket))
{
bucket = new Bucket();
_buckets[key] = bucket;
}
bucket.Matrices.Add(trs);
bucket.FrameSeg.Add(new Vector4(frameIndex, pixelSegmentation, 0f, 0f));
}
/// 每帧收集完后调用:遍历所有桶,逐桶 RenderMeshInstanced 提交(>1023 自动拆批)。
public void Flush()
{
foreach (var kv in _buckets)
{
Bucket bucket = kv.Value;
int total = bucket.Matrices.Count;
if (total == 0) continue;
//拆批:当有桶的实例数量大于1023时自动再分一批
for (int offset = 0; offset < total; offset += MaxBatchSize)
{
int count = Mathf.Min(MaxBatchSize, total - offset);
for (int i = 0; i < count; i++)
{
_matrices[i] = bucket.Matrices[offset + i];
_frameSeg[i] = bucket.FrameSeg[offset + i];
}
_mpb.Clear();
_mpb.SetVectorArray(FrameIndexSegID, _frameSeg);
RenderParams rp = new RenderParams(kv.Key.Material)
{
matProps = _mpb,
worldBounds = new Bounds(Vector3.zero, Vector3.one * 10000f), // 防 frustum cull 误剪
layer = 0, // 匹配相机 culling mask
};
Graphics.RenderMeshInstanced(rp, kv.Key.Mesh, 0, _matrices, count);
}
}
每帧调用
最后在gameplay中的合适位置每帧调用即可
/// <summary>
/// 每帧从每个实体收齐 instance 数据,按 (mesh, material) 分桶,一次性合批提交。
/// 矩阵必须用 MeshRenderer 所在节点的 localToWorldMatrix(坑4:不是逻辑根节点),
/// 否则节点层级局部变换(本 prefab 根节点 scale=3.6、rot.y=180)会造成叠影/错位。
/// </summary>
private void DriveInstancedRendering()
{
//这里为了图省事EntityInstancedRenderer我使用了单例
EntityInstancedRenderer inst = EntityInstancedRenderer.Instance;
if (inst == null)
{
return;
}
inst.BeginFrame();
for (int i = 0; i < renderInfos.Count; i++)
{
EntityRenderInfo info = renderInfos[i];
GPUSkinningPlayerMono mono = info.mono;
if (mono == null || mono.Player == null) continue;
GPUSkinningPlayer player = mono.Player;
// 首次拿到 player 时禁用相机剔除 / LOD(Instantiate 后 Start 跑完才有 player)。
// - CullingMode=AlwaysAnimate:否则视野外 res.Time 不推进 → 提交过时 frameIndex
// - LODEnabled=false:否则 LOD 会切换 MeshFilter.sharedMesh,导致分桶按 LOD mesh 再拆。
if (!info.cullingConfigured)
{
player.CullingMode = GPUSKinningCullingMode.AlwaysAnimate;
player.LODEnabled = false;
info.cullingConfigured = true;
renderInfos[i] = info;
}
if (info.bodyRenderer == null || info.bodyFilter == null) continue;
Mesh mesh = info.bodyFilter.sharedMesh;
GPUSkinningMaterial matWrapper = player.GetCurrentMaterialWrapper();
if (mesh == null || matWrapper == null || matWrapper.material == null)
{
continue;
}
player.GetFrameAndSeg(out float frameIndex, out float pixelSeg);
// 矩阵用 bodyRenderer 节点(实体本体所在的根节点)的 localToWorldMatrix。
inst.SubmitInstance(mesh, matWrapper.material,
info.bodyRenderer.localToWorldMatrix, frameIndex, pixelSeg);
}
inst.Flush();
}
效果展示
合批成功

主要流程总结如下流程图:
仍有疑问
既然合批失败的根本原因是_OutlineColor属性没有标记为Instanced并被每帧写入,那与Individual Difference Enabled动画功能有什么关系呢,Individual Difference Enabled动画是通过写入时间偏移来实现的,与描边颜色没有任何关系。为什么不开启Individual Difference Enabled就可以合批。
猜测:
Unity对mpb的合批判定有严格和非严格的区别,当mpb中的所有元素没有写入或者写入相同的值,unity保持非严格判定,只要值相等就放行合批。当mpb中有元素被写入不同的值,unity就触发严格检查,只要mpb中有非Instanced属性被写入,不管值是否更改都不能进行合批。
这只是我的猜测,真正的原因可能就要在后续深入学习中读取unity源码寻找了。

浙公网安备 33010602011771号