Unity GPU Instancing 合批失败问题排查与解决

Unity GPU Instancing 合批失败问题排查与解决

事情的起因是我的一个项目需要在场景中使用大量的GPUSkinning动画,在将项目打包后真机运行时发现,场景中GPUSkinning越多帧率越低。虽然项目场景中通常会有1~2k个GPUSkinning,但是这些实例都只是由11种不同的GPUSkinning克隆出来实例化的。同一种GPUSkinning使用相同的Mesh、Material,理应自动合批,但经过检查发现场景运行时的batches约等于乘客数量,由此断定合批失败,每个乘客一个draw call ,项目具有严重的性能问题。于是有了本文问题排查与解决过程。


关于合批检查

有两种方法可以查看当前场景的合批情况

  1. 点击Game视图右上角Stats按钮打开Statistics界面,其中的Batches就是批数量,通常与draw call数量相等
    image-20260814145647568
  2. 打开Frame Debugger窗口,查看Camera.Render下的Drawing事件,有合批的事件名称会有(instanced)标记。在右侧详情界面也可以看到当前事件的draw call数量和实例数量。
    image-20260814145805410

问题排查

项目种使用了一个GPUSkinning插件,也是个比较广为人知的插件了,这里贴出GitHub链接
GitHub - chengkehan/GPUSkinning · GitHub

通过查看GitHub仓库的介绍我断定该插件转换的GPUSkinning确实应该是能够自动合批的,所以首先我检查了插件设置:

检查材质:

该GPUSkinning插件转换后产生四个依赖产物,我们点击选中其中的材质球,检查材质是否开启Enable GPU Instancing

这个选项决定这个材质是否编译/允许使用 instancing 变体——只有开启了它,这个材质才有可能参与 GPU Instancing;不开启则一定不会合批。

image-20260814143732795

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

[!NOTE]

后来经过对插件深入了解发现,该插件会在运行时自动开启Enable GPU Instancing,材质是否单独设置没有影响。

检查动画:

此时,我想到我开启了Individual Difference Enabled这个功能,这是GPUSkinning插件提供的一个让动画差异播放的功能,打开之后不同实例的相同动画能够从不同的帧开始播放,实现每个实例差异化,不会整齐化一。

我推测会不会是因为每个乘客的动画差异导致了无法合批,于是我打开GPUSkinning转换后的so中,将所有动画的Individual Difference Enabled全部关闭,再次尝试。合批成功。

image-20260814151428807

image-20260814151516775

但是在插件的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写入,这才是导致无法合批的根本原因。

image-20260814161727302

问题解决

解决方案有两种:

  1. 删除这个_OutLineColor属性的每帧mpb写入
  2. 在程序中手动收集实例合批上报

我比较倾向于尽量不要随意改动别人制作的东西,所以我采用第二种方案。在程序中手动收集实例合批上报。

方案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 的两种路径

  1. 默认渲染路径:多个 MeshRenderer 共享同一 sharedMaterial + 同一 mesh + 无 MaterialPropertyBlock(或 MPB 只写 instancing prop),Unity 自动 batch。
  2. 手动提交路径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.RenderMeshInstancedmatrices 参数,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);
    }

下一步,我们在脚本中封装三个方法供外部调用:

  1. 每帧收集前清空桶缓存
  2. 将一个实例分桶
  3. 遍历所有桶,分桶提交渲染

其中,第三步通过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();
    }

效果展示

合批成功

image-20260814173224282

主要流程总结如下流程图:

flowchart TD A["Generate:<br/>批量 Instantiate 乘客"] --> B["BeginFrame:清空所有桶"] B --> C["遍历乘客:读取矩阵 + 动画帧号<br/>按 mesh+material 分桶"] C -->|收集完毕| D["Flush:逐桶提交"] D --> E["单桶超 1023 自动拆批"] E --> F["RenderMeshInstanced<br/>一批 = 1 次 draw call"]

仍有疑问

既然合批失败的根本原因是_OutlineColor属性没有标记为Instanced并被每帧写入,那与Individual Difference Enabled动画功能有什么关系呢,Individual Difference Enabled动画是通过写入时间偏移来实现的,与描边颜色没有任何关系。为什么不开启Individual Difference Enabled就可以合批。

猜测:

Unity对mpb的合批判定有严格和非严格的区别,当mpb中的所有元素没有写入或者写入相同的值,unity保持非严格判定,只要值相等就放行合批。当mpb中有元素被写入不同的值,unity就触发严格检查,只要mpb中有非Instanced属性被写入,不管值是否更改都不能进行合批。

这只是我的猜测,真正的原因可能就要在后续深入学习中读取unity源码寻找了。

posted @ 2026-08-14 17:51  CloverJoyi  阅读(13)  评论(0)    收藏  举报