项目概述:GPU Instancing 到底能解决什么问题
做 Unity 项目优化,尤其是移动端游戏,迟早会遇到一个绕不开的性能瓶颈:同屏物体太多,Draw Call(绘制调用)暴涨,帧率直接崩盘。大面积草丛、密集的碎石堆、满屏的敌人、铺天盖地的弹幕特效,这些东西单看每个模型面数都不高,但数量一上去,CPU 就忙于把一条条渲染命令发给 GPU,哪怕每个物体只有几百个三角形,也能把主线程活活拖垮。这时候,GPU Instancing(GPU实例化)几乎是唯一能够在不大幅牺牲画面效果的前提下,把性能拉回来的手段。
GPU Instancing 的核心思路非常简单粗暴:与其让 CPU 一个接一个地发送渲染命令,不如把一批相同模型的变换信息打包成一份数据,一次性交给 GPU,让 GPU 自己按这份数据绘制出成百上千个实例。用一句话概括,就是“相同的东西,告诉 GPU 画一百遍,而不是让 CPU 跑一百趟”。这套机制最早是桌面端图形 API 时代就有的老技术,在 DirectX 和 OpenGL 时代都有对应实现,而 Unity 把它封装成了开箱即用的功能,对项目开发者相当友好。
这篇内容适合谁?主要是那些已经在 Unity 里做过一段时间场景搭建、正被性能问题折磨的开发者——你可能试过把场景里几百棵树砍到几十棵才勉强跑得动,但你完全可以用 GPU Instancing 把它们全部保留下来,还能维持 60 帧。也适合那些刚开始接触渲染管线和批处理机制的初中级开发者,我会把原理、踩坑点、实操代码一次性讲透。读完之后,你至少能顺手完成一个“几千个同类物体同屏绘制不掉帧”的小场景,并且知道怎么判断项目里的什么东西适合做实例化、什么东西该绕开。
需要先说明一点:这篇文章聊的是 Unity 内置渲染管线(Built-in Render Pipeline)和 SRP(可编程渲染管线)下的 GPU Instancing 通用玩法,案例以标准着色器和自定义着色器为主。URP 和 HDRP 下大部分用法通用,但光照处理上有些差异,我会单独提。
1. 为什么你渲染了几万棵树,GPU 还在摸鱼
在动手写代码之前,先花点时间弄清楚这套机制为什么有效,否则你后面调参数、排查问题时会完全摸不着头脑。
1.1 CPU 才是“绘制成百上千物体”的真正瓶颈
很多人有个误区:画面卡了,第一反应是“GPU 不够强”。但实际上,大量小物体同屏的场景里,GPU 往往只用了不到一半的算力,真正累死的是 CPU。
传统美术资源的绘制流程大致是这样的:CPU 逐个处理每个物体的可见性判定、剔除操作,然后收集物体的网格、材质、变换矩阵,封装成一条 Draw Call,通过图形 API 传给驱动,驱动再交给 GPU 执行。一个物体一条命令,1000 个物体就是 1000 条命令。而每条命令从 CPU 到 GPU 的传递、驱动层对状态切换的处理、GPU 的提交等待,每一步都有不小的开销。现代显卡贵的是并行计算能力,不是这种顺序执行的小规格任务,你让 GPU 去处理 1000 个三角形级别的任务,它一个瞬间就画完了,但 CPU 把这 1000 次调用的参数准备好、发出去,可能就要耗时 10 到 20 毫秒。
打个比方:你要发 1000 张快递单给同一个仓库,如果一张一张打电话通知,光拨号就累死你;如果直接做一张 Excel 表发给仓库管理员,对方拿着表一次就能全发完。GPU Instancing 的本质就是把那张“Excel 表”整理好——一个网格、一个材质、一份数组(变换矩阵+颜色等自定义属性),用一条 Draw Call 提交,让 GPU 批量绘制。实测中,1000 个 Cube 用传统方式绘制可能需要 1000 条 Draw Call,而启用 Instancing 后通常能压到 1 条(受批次限制时是几条)。
1.2 GPU Instancing 和 Static/Dynamic Batching 不是一回事
Unity 里还有另外两种批处理机制:静态合批(Static Batching)和动态合批(Dynamic Batching)。很多人以为它们和 Instancing 是一回事,其实它们解决瓶颈的方式完全不同。
- 静态合批:把场景里标记为 Static 的物体,在构建时合并成一个大的网格。好处是运行时不需要 CPU 逐物体处理,代价是内存占用上涨(相当于为合批多存了一份合并后的网格),而且只适用于完全静止不动的物体。
- 动态合批:CPU 在每帧把多个小物体的小网格临时合并成一个网格再提交。机制上比 Instancing 更灵活,但只支持 900 个顶点以内的网格(Shader Model 2 平台是 300 顶点),而且每次合批都有 CPU 额外的网格合并开销,移动端上经常得不偿失。
- GPU Instancing:不合并网格,不走 CPU 拼网格的路,而是通过单个可被实例化的着色器 + GPU 并行实例化能力完成批量渲染。动态物体的支持非常好——你只需要更新传入的变换数组,不需要像 Static Batching 那样构建时固化一切。
遇到项目里的决策,我的常见建议是:大量静止物体(建筑、地形装饰)用静态合批或者直接烘进场景里,动态且重复的物体(敌人、弹幕、角色特效)用 GPU Instancing,而动态合批在移动端尽量少用,它的性价比在真实项目中往往不高。SRP Batcher 是另一条路,它是针对材质属性更新的优化机制,和 Instancing 能共存,后面我会提到,但二者解决的问题不同。
1.3 一个简单公式理解性能收益
如果你熟悉 Profiler 里的数据,可以把收益概括为:假设一个网格的常规 Draw Call 开销是 N(它由驱动调用、状态切换等组成),而实例化 batch 的单次开销约等于 1 + 数据上传开销(几十 KB 量级)。几千个实例的 CPU 侧开销可以从几千次降低到几十次,这是一个数量级以上的差距。GPU 侧因为大量实例并行生成,渲染管线的顶点处理阶段仍然要为每个实例跑一遍顶点着色器,但整体吞吐远高于提交上千条小命令,所以帧耗能显著下降。
需要警惕的是,实例化不是没有上限的。单个批次能容纳的实例数受平台和着色器限制,PC 上通常是 1024 个左右,移动端旧设备可能是 500 个。物体超过这个数量,Unity 会自动拆分成多个批次,本质上是“两条 Excel 表”,但和之前的上千次调用相比依旧是降维打击。
2. 动手前的准备:哪些物体能实例化
不是所有物体都能套 GPU Instancing,也不是所有材质天然支持。这个环节决定了你项目的最终优化上限,值得仔细看。
2.1 前提条件是“同一个材质 + 同一个网格”
Instancing 的本质是“一次提交、批量绘制”,所以它对物体有硬性要求:网格必须相同(允许不同缩放和位置,但不能不同 Mesh),材质必须相同(同一个材质实例,或者至少是同一个 Shader 且关键属性一致)。如果你的场景里有 100 棵材质完全一样的树,只是位置、大小、旋转不同,那完美符合;但如果其中有 5 棵树用了另一种绿色,那就得把这 5 棵单独拎出去,或者给它们单独准备一份实例化数据(用 MaterialPropertyBlock 指定不同颜色,详见后文)。
对于角色和敌人,如果一套模型对应多种颜色皮肤,同样不会直接实例化——除非你用 MaterialPropertyBlock 传入各自的颜色、粗糙度、贴图参数,这样才能把“长得不一样但是结构相同”的模型放进同一个批次。这是高级用法,我会在后面专门展开。
2.2 材质和 Shader 必须开启“Enable GPU Instancing”
即便你用了标准着色器(Standard Shader),材质面板上的实例化开关默认也是关的。需要手动勾选材质 Inspector 右下角的“Enable GPU Instancing”选项。这个勾选动作,本质上是让 Unity 把材质的属性从“每材质一份”编译成“每实例一份”的变体(keyword),着色器内部会生成支持实例化 ID 的属性和缓冲逻辑。
如果是你自己写的 Shader,需要做几件事:
- 在 Shader 末尾加上
#pragma multi_compile_instancing,让 Unity 生成支持实例化的变体(变体会增加打包体积,需要清楚这个成本)。 - 属性块的声明方式要允许实例化(使用
UNITY_INSTANCING_BUFFER_START(Props)语法在 CBUFFER 中声明实例化属性)。 - 顶点/片元着色器中需要通过
UNITY_SETUP_INSTANCE_ID(v);获取当前实例 ID,并且通过UNITY_ACCESS_INSTANCED_PROP访问实例化属性,这样 GPU 才知道你取的是哪个实例的数据。 - 如果用了
unity_ObjectToWorld矩阵,默认情况它在实例化 Shader 下会失效,你需要用UNITY_MATRIX_M宏来获取矩阵,它内部会根据是否实例化自动换算。
自己写实例化 Shader 是我在实际项目中踩过最深的一次坑。最初接手一个老旧项目,所有草地的 Shader 都是几个 TA 自己写的顶点动画 Shader,我当时没有在意 GPU Instancing 支持,结果大量草全部无法批处理,帧率惨不忍睹。后来逐行排查,发现就是少了UNITY_SETUP_INSTANCE_ID。这个宏只占一行,但忘了它,渲染结果直接错乱。
2.3 材质参数的随机化:MaterialPropertyBlock 的正确用法
实际场景里,几百个物体如果不做任何差异化,画面会呆板得像复制粘贴。解决思路是给每个实例传入少量差异化数据,比如颜色偏移、随机缩放、风力摆动幅度等。可以直接修改材质实例(会造成材质分离,破坏批处理),正确做法是用 MaterialPropertyBlock。
MaterialPropertyBlock 是一个轻量级的数据块,允许你为每个渲染器单独覆盖部分着色器属性,而不会创建新的材质实例。配合 GPU Instancing 使用,Unity 会把每个实例的 property block 属性打包进实例化数据 buffer,传给 GPU,GPU 在每实例访问时自动拿对应的属性值。
用法如下:
MaterialPropertyBlock props = new MaterialPropertyBlock(); for (int i = 0; i < count; i++) { renderer[i].GetPropertyBlock(props); props.SetColor("_Color", colors[i]); props.SetFloat("_RandomSeed", Random.value); renderer[i].SetPropertyBlock(props); }需要注意,MaterialPropertyBlock 里设置的属性必须是 Shader 里声明为实例化属性(位于UNITY_INSTANCING_BUFFER_START区块内)的字段,否则这些属性会导致该物体跳出实例化流程,回到逐物体绘制的低效路径。换句话说,写 Shader 的人要清楚地知道项目里哪些属性会被美术频繁差异化。
一个常见的迷惑点:材质面板上直接修改颜色能否做到差异化的同时保持实例化?答案是不能。材质是共享资源,你改了 A 物体材质的颜色,B 物体跟着一起变。想要“每实例不同颜色”,就必须靠 MaterialPropertyBlock。
2.4 Graphics.DrawMeshInstanced:绕过组件系统的高效路径
如果是大量临时物体、粒子、动态生成的地形植被,为每个物体创建一个 GameObject 并挂上 MeshRenderer 反而成为瓶颈,因为 GameObject 本身的 Update 开销、Transform 同步开销都在。这时候推荐直接用 Graphics.DrawMeshInstanced 或 Graphics.RenderMeshPrimitives 之类的 API。
基础用法:
using UnityEngine; public class InstancedCubeSpawner : MonoBehaviour { public Mesh mesh; public Material material; public int count = 10000; public float radius = 100f; private Matrix4x4[] matrices; private MaterialPropertyBlock block; void Start() { matrices = new Matrix4x4[count]; Vector4[] colors = new Vector4[count]; for (int i = 0; i < count; i++) { Vector3 pos = Random.insideUnitSphere * radius; Quaternion rot = Quaternion.Euler(Random.Range(0f, 360f), Random.Range(0f, 360f), Random.Range(0f, 360f)); Vector3 scale = Vector3.one * Random.Range(0.5f, 2f); matrices[i] = Matrix4x4.TRS(pos, rot, scale); colors[i] = new Vector4(Random.value, Random.value, Random.value, 1f); } block = new MaterialPropertyBlock(); block.SetVectorArray("_Colors", colors); } void Update() { Graphics.DrawMeshInstanced(mesh, 0, material, matrices, count, block, UnityEngine.Rendering.ShadowCastingMode.On, true); } }这段代码能在没有任何 GameObject 组件的情况下渲染出 10000 个 Cube,性能表现相当亮眼。你在 Update 里唯一要做的事就是更新 matrices 数组(比如让每个实例按自己的轴旋转),然后调用一次 API。
但这里有性能陷阱:matrices 数组是在 CPU 上维护的,每帧如果重算 10000 个矩阵且不做优化,CPU 侧虽然不会发 10000 条 Draw Call,但矩阵计算本身也可能吃几毫秒。真实项目中,通常会把计算迁到 GPU 端(Compute Shader 或者顶点 Shader 里做动画),CPU 只需要传一份静态的变换数组和一个全局时间变量即可。这也解释了为什么 GPU Instancing 常常和 GPU 驱动的动画方案(如 GPU 粒子、植被风动)配合使用。
3. 三种常用实战姿势:从简单到进阶
同一个需求,可以是简单场景里挂组件完事,也可以是大量生成且需要动态更新的复杂系统。我把常见做法整理成三档,你可以按项目情况选。
3.1 姿势一:少量物体,直接用 MeshRenderer + 材质开关
如果你的物体数量在几百以内,而且它们是场景中挂载了脚本、需要交互的(比如可以被拾取、被攻击),那就没必要用 Graphics.DrawMeshInstanced。直接创建 GameObject,给每个挂上 MeshRenderer,在材质上勾选 Enable GPU Instancing 就行。Unity 的渲染管线和 Culling 会尽量把它们合并到批次中。本质上,这是一种“让引擎自行发挥”的方式。前提还是材质必须相同,不能每棵树一个材质实例。
这种方案胜在简单,不会破坏 GameObject 体系,也方便后续做逐个物体的逻辑控制。但它的合并效率取决于 Unity 内部对场景渲染数据的提交方式,且遇到 UI 层级穿插时可能被打断。适合数量可控的装饰物、小规模摆放。
3.2 姿势二:大量静态物体,用 Graphics.DrawMeshInstanced 一梭子丢给 GPU
这就是上面代码示意的路线。适合数量上千、不需要单独交互逻辑的物体,最典型的案例是植被、碎石、重复的街景设施。
有几个优化细节值得强调:
- 剔除:
Graphics.DrawMeshInstanced同样会被 Unity 的相机视锥剔除?官方实现里,Unity 会根据参数传入的 bounds 做包围盒剔除,但这只是一个大包围盒(所有实例都在里面),不能做到精细到每个实例的剔除。因此,如果物体分散在地图各处,全图用一个超大包围盒,相机没看到的那部分也会继续绘制(只是还在视锥内的话)。解决方案有两个:一是手动按区块拆分调用,每个区块一个 DrawMeshInstanced,每个区块的 bounds 就是该区块物体包围盒;二是用 CullingGroup 或自定义判断决定哪几个区块需要绘制。 - 阴影投射:通过
castShadows参数控制。ShadowCastingMode.On 会使得每个实例参与阴影贴图渲染(又是一批实例化绘制),如果不需要阴影可以关掉,性能再上一个台阶。但要注意,大面积植被如果完全没有阴影,画面真实感会大打折扣,建议结合实际场景光照方案权衡。 - 接收阴影:实例化渲染的物体接收阴影模式下,如果用的是标准 Shader,材质里需要开启 Receive Shadows;自写 Shader 则要加入相应阴影采样代码,实例化下的采样逻辑和普通物体略有差异,很容易踩坑。
3.3 姿势三:高密度动态物体,配合 GPU 端动画
这一步是最能体现 GPU Instancing 精髓的地方。想想游戏里一片风吹动的麦田:麦穗数量可能达到 10 万甚至 100 万,每个麦穗都在随风摆动,如果 CPU 端每帧更新每个麦穗的矩阵,哪怕只是做正弦波偏移,100 万个矩阵的计算也会压垮 CPU。这时候正确做法是:
- 用一个静态的矩阵数组(只存每个麦穗的初始位置/旋转/缩放)传给 GPU。
- 在顶点着色器里根据实例 ID 获取初始矩阵,再叠加一个基于时间 + 位置(或随机种子)的风力偏移,让每个麦穗动态摆动。
- CPU 每帧只需要调用一次 DrawMeshInstanced 并传入一个全局时间变量,剩下的全部由 GPU 并行执行。
实现层面,在 Shader 里使用实例化属性:
UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float4, _Position) UNITY_DEFINE_INSTANCED_PROP(float, _RandomSeed) UNITY_INSTANCING_BUFFER_END(Props) // 在顶点着色器里: float4 worldPos = mul(UNITY_MATRIX_M, float4(v.vertex.xyz, 1.0)); worldPos.y += sin(_Time.y * 2.0 + UNITY_ACCESS_INSTANCED_PROP(Props, _RandomSeed) * 6.283) * 0.3;这样,你可以在一次渲染调用中处理几十万个动态顶点,而且 CPU 几乎不参与逐实例更新。这种模式在 PC 上可以做到百万级实例而帧率不掉,移动端根据 GPU 算力差一些,但几十万级依旧可行。
我做过一个“十万颗小行星环绕星球”的演示项目,恒星和陨石带全用这套方案。CPU 侧每帧只更新一个旋转角度参数,GPU 端用角度和实例 ID 计算每个小行星的轨道位置,最后单批渲染,Profiler 显示 Draw Call 只有个位数。这放在传统逐物体渲染下是不可想象的。
4. 核心实操:一个完整案例的前后对比
下面用一个可复现的完整案例,把整套流程从头到尾走一遍,并用 Unity Profiler 数据做个前后对比。
4.1 场景准备与原始性能采集
创建一个新场景,放置一个 Plane 作为地面,写一个生成器脚本:生成 5000 个相同 Cube(0.5 到 2 倍随机缩放),每个 Cube 上挂 MeshRenderer,使用同一个材质(Standard Shader,默认参数)。不勾选 GPU Instancing。运行场景,打开 Window > Analysis > Profiler,观察 Draw Call 数和帧耗时。
在这个配置下,5000 个 GameObject 的 Draw Call 大约是 5000(实际可能合并一部分,因为同一材质同一网格下 Unity 会自动做动态合批?不会——5000 个相同 Cube 使用相同材质,顶点数少,动态合批可能介入,但实际上受顶点数限制且场景复杂后不一定合批)。为了数据稳定,建议直接使用不同缩放和旋转,因为矩阵不同,动态合批是不参与的。观察到的帧耗时在我的测试设备上大约 35~50ms,Draw Call 在 5000 左右,CPU 和 GPU 耗时都很高。
接着,把材质面板下方的 Enable GPU Instancing 勾上。再次运行,Draw Call 会骤降到 8 条以内(5000 / 1024 ≈ 5 批)。帧耗时降到 10ms 左右。这一对比让人立刻明白 GPU Instancing 的价值:启用前瓶颈在 CPU 提交命令,启用后 CPU 几乎不再参与逐物体提交,GPU 并行能力被充分利用。
4.2 用 Graphics.DrawMeshInstanced 移除组件开销
第二步,去掉 5000 个 GameObject,改为一个空 GameObject + 生成器脚本,使用 DrawMeshInstanced 渲染。创建矩阵数组时,确保包含位置、旋转、缩放;材质同样开启实例化;MatieralPropertyBlock 给每个实例赋随机颜色。这一步不仅能减少 Draw Call,还会省去 GameObject 的 Transform 管理和每帧脚本遍历,CPU 侧主线程耗时从 20ms 级别降到 1ms 以下。
代码略作扩展,支持每帧轻微旋转以展示动态更新成本:
void Update() { for (int i = 0; i < count; i++) { Quaternion deltaRot = Quaternion.Euler(0f, 30f * Time.deltaTime, 0f); matrices[i] *= Matrix4x4.Rotate(deltaRot); } Graphics.DrawMeshInstanced(mesh, 0, material, matrices, count, block); }注意,这种 CPU 更新矩阵的写法在 5000 个实例时没问题,但到了 5 万、50 万时就会暴露瓶颈。如果要做 50 万级别,请直接走 GPU 端动画,矩阵数组保持静态,把动画放到 Shader 里。
4.3 数据测量与合批数量核算
实例化批次数量公式:ceil(实例总数 / 单批次最大实例数)。单批次最大实例数取决于 shader 中声明的实例化属性和平台常量缓冲限制。PC 上最常见的值是 1024,因此:
- 5000 实例:5000 / 1024 = 4.88,向上取整 5 批。
- 10000 实例:10000 / 1024 = 9.76,向上取整 10 批。
- 50000 实例:50000 / 1024 = 48.8,向上取整 49 批。
这些批次之间没有状态切换开销,每批内部的缓冲已经打包好,所以 49 批的耗时远小于 50000 批。实测比你用 Profiler 能看到的 DrawCall 计数少很多,因为 DrawMeshInstanced 在 Profiler 的一帧统计里显示为一次 DrawMeshInstanced 调用,而不是 49 次 Mesh.Draw 条目。
还有个小知识点:单批次最大实例数还受限于着色器里声明的实例化属性数量。属性越多,每个实例需要上传的数据量越大,批次容量反而下降。比如你为每个实例传了 4 个 float4 颜色向量,批次容量可能降到 512 或更低,不要在材质里放一堆不必要的实例化属性。
5. 容易翻车的几个场景和排查思路
技术本身不复杂,但在真实项目里会遇到各种意料之外的“不生效”情况。我按踩坑概率从高到低列几个典型。
5.1 为什么勾了 GPU Instancing 依旧大量 DrawCall
最常见的原因:物体之间使用了不同的材质实例。场景里 2000 棵树,如果每个树的材质实例是复制出来的(哪怕是同一个 shader),Unity 无法把它们当作相同的材质,实例化直接失效。排查用 Frame Debugger,看 Material 名称后面的 ID 是否一致。
另一个核心原因:网格不同。比如树的 Mesh 可能经过切割,顶点顺序不同、光照贴图 UV 通道不同,都不能合批。实例化严格要求相同 Mesh,连 UV 通道布局都得一致。
还有一部分原因是物体数量太少或 LOD 切换频繁:只要有一个 LOD 层级的材质或 Mesh 不同,实例化就被打断。建议 LOD 每级的网格用同一个网格但不同简化版本(建模软件导出多级),材质保持同一个。
5.2 阴影和光照贴图带来的批处理断裂
使用光照贴图时,每个物体需要的光照贴图索引可能不同,这会破坏实例化。解决办法是把光照贴图烘焙到共享的全局纹理或者关闭实例化物体的实时接收,改用手动传入的一套全局光照数据(比如烘焙到顶点色/纹理数组)。项目里有大量实例化植被时尤其要注意。
阴影投射对批处理的影响:只要所有实例在同一个阴影批次(一张阴影贴图)内,问题不大。但如果部分物体投射阴影,部分不投射,或者投影距离设置不同,批次可能分裂。建议统一设置。
5.3 移动端:为什么某些设备上实例化效果更差
移动端 GPU 架构和桌面的并行方式有差异,某些老旧的 Mali GPU 上实例化绘制虽然减少 CPU 负载,但 GPU 侧的并行效率不如预期,甚至因为常量缓冲大小受限,批次容量被压得很小。建议:
- 尽量精简实例化属性,每个实例属性控制在 2~4 个 float4 以内。
- 测试覆盖中低端设备,尤其是 GPU 跑不动大量实例化动画的情况。
- 考虑使用
UNITY_INSTANCING_BUFFER_START/END区块内尽量放half4而不是float4?实际上 Unity 的实例化缓冲用 float4 对齐,half 不会节省太多,但减少属性数量有效。 - 如果你的目标是老设备,单独的实例化
#pragma multi_compile_instancing变体打包进去也可以接受,但历史原因会导致 shader 变体膨胀,做好变体管理。
5.4 颜色随机化失效:检查属性是否真的在实例化缓冲区
很多人在 MaterialPropertyBlock 里设置颜色,打开 Frame Debugger 发现实例化还是生效的,但渲染出来的所有物体颜色完全相同。这是因为 Shader 声明了该属性为普通材质属性(在Properties块和普通 CBUFFER 内),而 MaterialPropertyBlock 里的值虽然被传入,但没有被实例化渲染管线读取。正确做法是确保该属性以UNITY_DEFINE_INSTANCED_PROP形式声明,并且 Shader 代码里用UNITY_ACCESS_INSTANCED_PROP访问。如果用了 SRP 管线,自写 Shader 时还要注意 SRP Batcher 和实例化的共存逻辑,某些位置需要额外处理。
检查这个问题的快捷方式:打开 Frame Debugger,点开 Batch 节点,查看网格实例编号是否为一个较大的数值(几百或几千),并且 Shader Properties 面板里是否出现了unity_InstanceID和 per-instance properties 区域。
5.5 粒子系统和 Trail Renderer 的“伪实例化”
Unity 的粒子系统(Particle System)自带 GPU Instancing 支持,在 Renderer 模块中可以勾选 Enable Mesh Instancing,但前提是使用的 Shader 开启实例化变体,且粒子网格必须一致。Trail Renderer 目前内置管线中不支持实例化,至少到 Unity 2022 LTS 仍然如此,如果做弹幕特效需要避开。生产线性的粒子效果时,可以考虑用 Mesh 粒子系统配合实例化,效果和性能都有保障。
6. 实例化相关的性能进阶玩法
当你已经吃透了 GPU Instancing 的基础用法之后,接下来这几个方向能进一步拉开性能差距,也是高级项目或技术美术面试中经常被问到的点。
6.1 结合 CullingGroup 做分区域实例化剔除
当场景超大时,一个 DrawMeshInstanced 调用覆盖全图会带来无效的 GPU 处理。前面提过,手动按区块拆分并做视锥剔除是标准做法。CullingGroup 是 UnityEngine 提供的高效剔除工具,它可以在主线程之外帮你高效判断大量点/包围盒是否在相机视锥内,并回调事件告诉你哪些区块可见。把物体按网格区块组织,每帧只对可见区块调用 DrawMeshInstanced,可以有效减少不可见实例的顶点处理。
更进阶的方案是 GPU Driven Rendering,Unity 官方在 2021.2+ 版本中逐步开放了Graphics.RenderMeshIndirect等接口,可以配合 Compute Shader 在 GPU 端完成剔除工作,CPU 完全不碰不可见物体数据。这个方案复杂度高,但移动端旗舰手机上表现极佳,采用者包括不少 3A 级手游。建议完成基础实例化后再研究它。
6.2 与 SRP Batcher 共存:别把两个“批处理”搞成互斥
很多用 URP 的开发者有个误解:SRP Batcher 会替代 GPU Instancing。实际上,SRP Batcher 专门优化“不同材质属性但相同 Shader”这一场景,它通过缓存渲染命令来减少 CPU 侧的设置开销;实例化则解决“同材质同网格多实例”场景,两者解决的问题不同。在 URP 下,一个物体既可能被 SRP Batcher 处理,也可能在特定情况下走实例化路径,它们可以共存。实践上,如果大量物体共用同一个材质(比如草地),实例化有更高收益;如果只是一堆材质不同但有相同 Shader 的角色,则 SRP Batcher 更合适。
6.3 实例化动画带来的“蒙皮”烦恼
GPU 蒙皮动画(SkinnedMeshRenderer)没有直接的 Instancing 支持,特别是骨骼渐变动画,每个角色的骨骼矩阵数组不同,无法用简单的矩阵数组方式实例化。如果项目里需要大量同模型角色,方案是:
- 顶点动画/纹理化骨骼动画(Texture Baked Animation):把骨骼动画烘焙到纹理,传入实例化属性,顶点着色器采样纹理获得每帧骨骼偏移。
- 用
Graphics.DrawMeshInstanced配合 LOD 和动画纹理,实现同屏几百个角色。
市面上成功的多人竞技游戏和割草玩法游戏常用这类方案,值得深挖。但简单的“挂了 SkinnedMeshRenderer 就能实例化”是不存在的,需要专门做动画管线改造。
7. 常见问题速查与几个值得记住的参数
整合一下之前零散提到的经验,做成一个速查表格,方便你在项目遇到问题时快速定位。
| 症状 | 可能原因 | 快速排查/解决 |
|---|---|---|
| 勾选了 Enable GPU Instancing,Draw Call 不变 | 材质实例不同/网格不同/LOD 打断 | Frame Debugger 看 Batch 数量与材质 ID |
| 实例化后画面颜色全部一样 | 属性未声明为实例化属性 | Shader 中用 UNITY_DEFINE_INSTANCED_PROP 并正确访问 |
| 实例化后阴影错乱或没有阴影 | 自定义 Shader 缺少阴影实例化支持 | 在阴影 Pass 中加入 multi_compile_instancing 和实例化宏 |
| 移动端 FPS 不升反降 | 实例属性过多/老 GPU 带宽受限 | 精简属性,测试多台设备,必要时退回静态合批 |
| 大批量物体闪烁或位置错乱 | 矩阵数组越界/批次容量超限 | 检查实例数是否超过单批次上限,确认 batchCount 参数 |
| 实例化后无法接收阴影 | 材质设置/Shader 采样层面问题 | 确认 Standard Shader Receive Shadows,自写 Shader 检查阴影宏 |
几个常用参数:
UNITY_INSTANCING_BUFFER_START(Props)和UNITY_INSTANCING_BUFFER_END:声明实例化属性缓冲区。UNITY_SETUP_INSTANCE_ID:在顶点/片元着色器开头获取当前实例 ID。UNITY_TRANSFER_INSTANCE_ID/UNITY_ACCESS_INSTANCED_PROP:实例化属性在片元阶段的传递与访问。#pragma multi_compile_instancing:生成实例化变体。UNITY_MATRIX_M:实例化场景下获取正确的模型矩阵(老代码里用unity_ObjectToWorld会在实例化时失效)。
写在最后的一个小经验
我在实际项目里做 GPU Instancing 踩过几次坑之后,最大的体会是:这一技术本质上是在拿 GPU 的并行能力和带宽换 CPU 的串行负担,所以优化目标一定要明确。如果你项目里有一万棵树,但每棵树的动画都需要单独逻辑,那实例化的收益会被逻辑开销吃回去;如果你的瓶颈本来就在 GPU 像素填充率上,那 Instancing 也救不了你。先开 Profiler 确认瓶颈在哪,再决定用不用这套方案。
另外一个实用建议:把“哪些材质开启了实例化、哪些没开”做成项目规范,写进团队的材质管理文档里。美术同学随手复制一个新材质是常有的事,一个没勾选项的材质能摧毁你所有优化成果。可以在 OnValidate 或构建 CI 脚本里检查——这虽然多花点功夫,但长期收益巨大,尤其在多人协作的项目里。
这个内容后续还可以这样扩展:结合 GPU Driven Rendering 和 GPU Occlusion Culling 做一个万级物体全 GPU 交付的完整演示,或者把《原神》式的大世界植被方案拆开讲解。到时候可以再写一篇详细笔记。