全国旗舰校区

不同学习城市 同样授课品质

北京

深圳

上海

广州

郑州

大连

武汉

成都

西安

杭州

青岛

重庆

长沙

哈尔滨

南京

太原

沈阳

合肥

贵阳

济南

下一个校区
就在你家门口
+
当前位置:首页  >  技术干货

Gpu Instancing技术

发布时间:2022-08-29 16:08:05
发布人:qyf

  一、前言

  在3D游戏中,制作者希望能够绘制越来越多的场景物体,比如场景中大量的植被(树木,草,花等),能够给玩家带来更加逼真的体验。但是这对对于设备(尤其是移动端)的性能是个极大的考验,如果使用传统的技术,大批量渲染会导致drawcall增加,fps下降。unity推出了Gpu Instancing技术,这对于大量相同物体的绘制提供了一个新的方案,我们尝试在unity中使用gpu instance 技术。

  二、静态物体使用Gpu Instance

  Gpu Instance是一种用来提高渲染大量物体效率的技术,在场景里绘制越来越多的物体,这里面主要涉及两个方面的性能瓶颈,一是cpu对gpu提交数据的次数(包括设置数据buffer,渲染状态以及调用对渲染原语的绘制即drawcall),二是gpu上的绘制(包括顶点处理和像素绘制),随着场景物体的提升,cpu和gpu的压力都会上升。目前在一些典型的3D游戏的制作中,我们的经验值是全屏不超过10万个顶点和200个draw call左右,不然对中端机器会有一定压力。

  为了解决场景绘制效率这个问题,主要有以下几种优化方案:

  static batching: 即静态合批,静态合批的原理即化整为零,将多个场景物体预先合成一个大的物体进行绘制,unity5的实现就是整合成一个大的vbo,而不整合IBO,一次性提交vbo给gpu,然后并不是把整个vbo都绘制,而是每次需要绘制其中某个某些物体时改变IBO,选择大vbo上的某一段进行绘制。静态合批可以将多个小物体的绘制合并成一个大物体的绘制,减少对渲染状态的改变,它一次并行绘制多个物体,理论上是最快的绘制方法,不过最大的缺点是因为合成新的大vbo需要耗费额外的大量内存,同时不能渲染动态物体,因为合并vbo的时候已经确定顶点数据了,顶点数据不能更改(例如unity5对LOD合批的实现也是讲所有层次的lod都预先合并进去),另外一个vbo的大小是有限制的,如果物体数量过多,也会被拆成多个绘制。

  dynamic batching:动态合批,可以解决对顶点数据有变化的物体的合批,它动态的合并vbo进行提交,组建vbo的时间有消耗,为了减少这个消耗,unity对动态合批的vbo大小有限制,以致于很小顶点数的物体才有可能被动态合批。

  vertex constant instancing:Instancing 是不同于batching的另一种方案,它的原理是对于模型一致的物体,只提交原始的模型的vbo给gpu,然后将每个物体不同的属性单独抽出来组成buffer发给gpu,在显卡中根据这一份vbo和每个物体不同的属性来绘制多个物体,即一次提交,在gpu上绘制多个,对于大量同样模型的物体绘制是一个很好的方案。vertex constant的instancing是利用顶点常量属性来存储这些per instance attributes,但是也需要一个大的vbo存储所有未经顶点变换的相同的n个原顶点数据,在shader里面读取不同的vertex constant内容绘制不同的instance

  gpu instancing:这是最新渲染api提供的一种技术,如果绘制1000个物体,它将一个模型的vbo提交给一次给显卡,至于1000个物体不同的位置,状态,颜色等等将他们整合成一个per instance attribute的buffer给gpu,在显卡上区别绘制,它大大减少提交次数,它在不同平台的实现有差异,例如gles是将per instance attribute也当成一个vbo提交,然后gles3.0支持一种per instance步进读取的vbo特性,来实现不同的instance得到不同的顶点数据,这种技术对于绘制大量的相同模型的物体由于有硬件实现,所以效率最高,最为灵活,避免合批的内存浪费,并且原则上可以做gpu skinning来实现骨骼动画的instancing。

  Unity5中实现instance

  unity中提供了两种使用gpu instance的机制,自动和手动:

  自动:需要使用unity 标准的standar 或surfaceshader,然后在mat下面的instacne那里打勾,然后unity在条件合适的情况下自动instance,但是注意这种限制非常多,如不能static batch,不能liaghtmap,不能改变mat,不能带动作,不能cull,等等,非常难,详见https://docs.unity3d.com/Manual/GPUInstancing.html

  手动:通过使用Graphics.DrawMeshInstanced或者Graphics.DrawMeshInstancedIndirect这些底层api。

  由于unity自动的instance不稳定且不能lightmap等等,于是我们的实现方案是自己用底层api去实现instance,并且自己去实现了支持lightmap和culling的instance。

  三、带骨骼动画的物体使用Gpu Instance

  gpuinstancing可以很好的工作在静态物体上,例如草,树。但遗憾的是暂时还无法对骨骼动画使用这个特性。而我们游戏经常使用上百个小兵单位作战,如果可以让小兵使用这个特性,那么对于性能的提升无疑是很可观的。于是有人提出了将动画信息烘焙到贴图中,在shader里面根据贴图设置顶点位置,也就是我们的顶点动画。这样的话,模型就既可以像骨骼动画那样播放动作,又可以使用gpuinstancing合批了。做法也非常简单,就是把Skinmesh变成MeshRender,然后把骨骼和动画信息,记录在一张贴图上,然后把动画的运算放到shader里。 本来这样就可以了,但实际使用过程中却发现了几个问题。

  1.烘焙的贴图过大,因为为了存储浮点数,必须使用rgbahalf的格式,这个格式每个像素有64个字节,是真彩色的两倍。假设一个小兵有1000个顶点,那么1s的动作就需要1000*64,也就是64000个字节,而正常情况下,我们小兵在2000个顶点左右,动画在5s以上,那么每个动画贴图大概就在2M以上,甚至有可能是4M。而我们有60多个兵种,这样一算竟然有240M。虽然小米超神使用了RGMB来减少每个像素的大小,但那也高达120M的动画贴图了。而我们知道,原始的骨骼动画数据其实只有几百k左右。

  2.无法计算光照,因为法线始终保持T-pos形态,在shader里面改变顶点位置的时候,无法重新计算法线。为了能够使用正常的光照计算,必须将法线也一起烘焙。幸运的是法线都是单位向量,可以采用rgba存储,但也需要大概1M左右的空间。

  3.没有动画之间的blend,为了实现blend,必须对两个动作的贴图进行采样,然后lerp。这样会导致shader里放两张4M的贴图,对手游来说还是不小的开销。

  综上所述,我最终还是采纳了M神的建议,使用了烘焙骨骼信息的方案。

  来看看原理,烘焙顶点很好理解,就是把位置的值存到贴图中。那么如何烘焙骨骼信息,然后得到顶点位置呢?首先我们要理解骨骼动画的原理,这里引用UWA博客里面的一段话:

图片4

  当然上面的描述很简单,如果想要了解更加详细的推倒过程,可以看Milo大神的书《游戏引擎架构xxx》里面的蒙皮的数学这一章。

  总之,结论就是从当前骨骼的bindpos一直左乘到根骨骼。

  代码也非常简单:

  for (int j = 0; j < bones.Length; j++)

  {

  GPUSkinningBone currentBone = bones[j];

  Matrix4x4 lastMat = currentBone.bindpose;

  while (true)

  {

  if (currentBone.parentBoneIndex == -1)

  {

  Matrix4x4 mat = Matrix4x4.TRS(currentBone.transform.localPosition, currentBone.transform.localRotation, currentBone.transform.localScale);

  if(rootBone.transform != go.transform)

  {

  mat = Matrix4x4.TRS(Vector3.zero, Quaternion.identity, go.transform.localScale) * mat;

  }

  lastMat = mat * lastMat;

  break;

  }

  else

  {

  Matrix4x4 mat = Matrix4x4.TRS(currentBone.transform.localPosition, currentBone.transform.localRotation, currentBone.transform.localScale);

  lastMat = mat * lastMat;

  currentBone = bones[currentBone.parentBoneIndex];

  }

  }

  animMap.SetPixel(j * 3, k + 1, new Color(lastMat.m00, lastMat.m01, lastMat.m02, lastMat.m03));

  animMap.SetPixel(j * 3 + 1, k + 1, new Color(lastMat.m10, lastMat.m11, lastMat.m12, lastMat.m13));

  animMap.SetPixel(j * 3 + 2, k + 1, new Color(lastMat.m20, lastMat.m21, lastMat.m22, lastMat.m23));

  if (k == startFrame)

  {

  animMap.SetPixel(j * 3, k, new Color(lastMat.m00, lastMat.m01, lastMat.m02, lastMat.m03));

  animMap.SetPixel(j * 3 + 1, k, new Color(lastMat.m10, lastMat.m11, lastMat.m12, lastMat.m13));

  animMap.SetPixel(j * 3 + 2, k, new Color(lastMat.m20, lastMat.m21, lastMat.m22, lastMat.m23));

  }

  else if(k == curClipFrame1 + startFrame - 3)

  {

  animMap.SetPixel(j * 3, k + 2, new Color(lastMat.m00, lastMat.m01, lastMat.m02, lastMat.m03));

  animMap.SetPixel(j * 3 + 1, k + 2, new Color(lastMat.m10, lastMat.m11, lastMat.m12, lastMat.m13));

  animMap.SetPixel(j * 3 + 2, k + 2, new Color(lastMat.m20, lastMat.m21, lastMat.m22, lastMat.m23));

  }

  }

  最重要的部分就是生成矩阵的那里。这里有几个注意点,一个是根骨骼可能有多个,那么你只需要将他们共同的父亲放到根节点,把这个其实没有骨骼的节点处理成默认矩阵的情况就可以。第二个是因为贴图采样有可能采样到边缘,为了防止精确度不够引起动画抖动,我前后各多增加了一帧,防止抖动。

  然后是shader部分:

  v2f vert(appdata v)

  {

  UNITY_SETUP_INSTANCE_ID(v);

  float start = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimStart);

  float end = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimEnd);

  float off = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimOff);

  float speed = UNITY_ACCESS_INSTANCED_PROP(Props, _Speed);

  float _AnimLen = (end - start);

  float f = (off + _Time.y * speed) / _AnimLen;

  f = fmod(f, 1.0);

  float animMap_x1 = (v.uv2.x * 3 + 0.5) * _AnimMap_TexelSize.x;

  float animMap_x2 = (v.uv2.x * 3 + 1.5) * _AnimMap_TexelSize.x;

  float animMap_x3 = (v.uv2.x * 3 + 2.5) * _AnimMap_TexelSize.x;

  float animMap_y = (f * _AnimLen + start) / _AnimAll;

  float4 row0 = tex2Dlod(_AnimMap, float4(animMap_x1, animMap_y, 0, 0));

  float4 row1 = tex2Dlod(_AnimMap, float4(animMap_x2, animMap_y, 0, 0));

  float4 row2 = tex2Dlod(_AnimMap, float4(animMap_x3, animMap_y, 0, 0));

  float4 row3 = float4(0, 0, 0, 1);

  float4x4 mat = float4x4(row0, row1, row2, row3);

  float4 pos = mul(mat, v.vertex);

  float3 normal = mul(mat, float4(v.normal, 0)).xyz;

  v2f o;

  UNITY_TRANSFER_INSTANCE_ID(v, o);

  o.uv = TRANSFORM_TEX(v.uv, _MainTex);

  o.vertex = UnityObjectToClipPos(pos);

  o.color = float4(0, 0, 0, 0);

  o.worldNormal = UnityObjectToWorldNormal(normal);

  float3 normalDir = normalize(mul(float4(normal, 0.0), unity_WorldToObject).xyz);

  float frezz = UNITY_ACCESS_INSTANCED_PROP(Props, _Frezz);

  float3 normalWorld = o.worldNormal;

  fixed dotProduct = dot(normalWorld, fixed3(0, 1, 0)) / 2;

  dotProduct = max(0, dotProduct);

  o.color = dotProduct.xxxx * frezz;

  return o;

  }

  主要就是顶点着色器部分,我们把4x4的骨骼旋转偏移矩阵存在贴图里,因为最后一行是flaot4(0,0,0,1),为了节省空间,我们只存了3x4大小的矩阵,最后一行在shader里补上。然后直接将矩阵和顶点相乘,就可以得到蒙皮后的顶点位置。而且我们看到,法线也可以这么处理,就可以得到蒙皮后正确的法线。这里还有一个我没有做的功能,就是骨骼权重,其实我将骨骼权重存进了顶点的uv2中,uv2.xy是第一根骨骼的索引和权重,uv2.zw是第二根骨骼的索引和权重,理论上需要将两个骨骼结算的结果加权平均一下,但因为我测试发现精度够了,就少采样一次,节省点消耗。如果有需要,可以自己加上这个加权平均。

  还有一个未来需要做的,就是动画之间的blend,需要额外增加一个变量控制blend的程度,对两个时刻的动作分别采样计算,然后lerp一下就可以了。

  我们看看用贴图存储骨骼需要的大小,假设一个小兵有25个骨骼,那么一个骨骼需要4x3个浮点数,也就是3个像素,那么需要75个像素,一个1s的动画,也只需要75*64,大概4800字节而已。而且重要的是我们不受到顶点数的限制,而一个小兵的骨骼正常情况下就是30以内,我们得到了一个可控的合理的结果。

  四、总结

  使用Gpuinstance技术能极大的提示游戏的渲染性能,让游戏能够渲染更多的植被和动态物体,提高玩家的游戏乐趣。

  更多关于unity游戏培训的问题,欢迎咨询千锋教育在线名师。千锋教育拥有多年IT培训服务经验,采用全程面授高品质、高体验培养模式,拥有国内一体化教学管理及学员服务,助力更多学员实现高薪梦想。

相关文章

为什么工业控制领域多用PLC?

为什么工业控制领域多用PLC?

2023-10-14
Java为什么不使用多继承??

Java为什么不使用多继承??

2023-10-14
IT驻场与软件外包有什么区别?

IT驻场与软件外包有什么区别?

2023-10-14
ITSS各级别之间有什么联系?

ITSS各级别之间有什么联系?

2023-10-14

最新文章

常见网络安全面试题:Windows常用的命令有哪些?

常见网络安全面试题:Windows常用的命令有哪些?

2023-10-09
常见网络安全面试题:根据设备告警如何展开排查?

常见网络安全面试题:根据设备告警如何展开排查?

2023-10-09
常见网络安全面试题:mysql加固呢?(数据库加固)

常见网络安全面试题:mysql加固呢?(数据库加固)

2023-10-09
常见网络安全面试题:windows和linux加固?(操作系统加固)

常见网络安全面试题:windows和linux加固?(操作系统加固)

2023-10-09
在线咨询 免费试学 教程领取