Constant Buffer的高效使用,让你码出质量

Constant Buffer的高效使用,让你码出质量

Unity中的实现

在Unite2017上unity的工程师给出了一份PPT的下载:pan.baidu.com/s/1jI3IYx


根据其PPT中的代码片段,我们是可以看出Unity的GPU Instance的实现方式,其实也是用到了Constant Buffer的,而且Constant Buffer的大小其实也是有限制的,也就是64KB(这个64kb的限制应该是受GPU中constant memory的限制或者是寄存器的限制,这里有点模糊)。


当然这里说的Constant Buffer的大小有限制是说单个Buffer的限制,而不是所有Constant Buffer的限制,意味着其实你可以创建很多个Constant Buffer。


微软官网关于Shader Constants有这块的说明,同一个着色器阶段可以最多绑定14个Constant Buffer(msdn.microsoft.com/en-u.aspx)(DirectX支持16个输入插槽)。


而StructureBuffer的话应该不受这个空间限制,猜想可能存储在Local Memory内或者Shared Memory(这个也只是我个人的理解,欢迎拍砖校正)


Constant Buffer引入缘由

Constant Buffer的引人其实是在DirectX 10,在DirectX 9中其实并不存在Constant Buffer这个概念,在DirectX 9中发送到GPU命令缓冲区80%的数据是提交着色器常量数据(Constant Data),而很多引擎则是发送所有的绘制调用更新,这显然是比较浪费的。而在DirectX 9中不同着色器阶段访问的相同的数据是不能保存的,只能在相应阶段进行设置,例如顶点着色器阶段与像素着色器阶段分别访问相同的常量,你不得不设置两次常量。即在常量缓冲区出现之前,切换着色器,没有任何办法来保存之间存在的常量。


常量缓冲区改变了这一现状,它是存储着色器常量的值的对象,在需要改变时才更改。常量缓冲区可以绑定到不同的着色器阶段,所以没必要存储多次。但是不幸的是将相同常量缓冲区绑定到不同着色器阶段后,更新常量缓冲区将变的更加的昂贵,因此在实际使用过程中我们应该尽可能的避免这样的情况。


应当注意的情况

首先、避免更新大的常量缓冲区中的小部分数据。当初看DirectX 10的文档时,对于Constant Buffer这一块,就有说明,根据更新的频率将其更新对象放置在不同的缓冲区中,如每帧只更新一个的wvp矩阵,又如每个对象都需要设置的transfrom信息,我们可以如此定义:

cbuffer cbPerObject
{
   float4x4 gWVP;
};
cbuffer cbPerFrame
{
   float3 gLightDirection;
   float3 gLightPosition;
   float4 gLightColor;
};

其次、注意合理输入布局。类似于C/C++中的内存对其一般,Constant Buffer的对其是16个字节即128位进行对其,因此我们如果想要即节省带宽,又能达到告诉传输效率,那么我们就需要合理的对其进行布局了(我查了一下,OpenGL类似也有这么个规则,叫std140的准则,不过OpenGL ES中目前没发现,而DX12已经是256位对齐了)。


如下我列举一不合理示例:

cbuffer cbPerObj 
{
   float2 uv0;
   float3 normal;
   float2 uv1;
};

上面的Constant Buffer中,将会占用16 * 3 = 48 byte的空间 由于padding原则,而我们可以通过调整uv1和normal的位置,将其减少为16 * 2 = 32 byte的空间


cbuffer cbPerObj 
{
   float2 uv0;
   float2 uv1;
   float3 normal;
};

这里额外的说一下StructureBuffer方面的布局,StructureBuffer似乎没有自动的padding,但128位这个访问偏移还是存在的,所以如果不合理的布局也会造成访问性能受损。

如下:

Struct obj 
{
   float4 position;
   float  r;
};

上面的结构刚好也就是占用20个字节,一个结构定义在连续的内存上存在跨块访问,因此我们可以稍微的牺牲点带宽,填几个占位坑,避免这种跨块访问

Struct obj 
{
   float4 position;
   float  r;
   float  pad0;
   float  pad1;
   float  pad2;
};

然后、就是避免一些操作因为更新常量缓冲区的代价是极其昂贵的,上面提到合理的输入布局即16字节对其能够提高内存拷贝的速度,即dx11中Map操作时的内存拷贝速度。


同时我们需要注意一些事项,如避免在合并写入时进行读操作。为什么呢?因为CPU向图形设备接口合并写入的时候,会对写入缓存一段时间,将多个相邻的写入合并成为一个更大的总线事务,这比单个写入要快很多。

然而当你在合并写入的内存上进行读取是,将会被视为未缓存的操作,同时也意味着所有待合并的写入缓存区都将被刷新,执行读取而不需要进行任何的缓存,刷新合并写入缓存区是会花费时间的,同时导致部分高速缓存进行存储也是低效的,同时未缓存的读取也是低效的。


这里举个例子,我们在填充缓冲区时进行以下操作:

pCb = (CPUTModelConstantBuffer*)mapInfo.pData;  
pCb->World               = world; 
pCb->ViewProjection      = view * projection; 
pCb->WorldViewProjection = world * pCb->ViewProjection;//这里注意

上面这段代码,在给WorldViewProjection设置值的时候,我们取了其ViewProjection的内容,这将导致整个缓存取刷新,并且不缓存。而我们可以改变的方法则是,在事先给ViewProjection赋值的那个矩阵操作缓存起来。


XMMATRIX viewProj = view * projection;  
pCb = (CPUTModelConstantBuffer*)mapInfo.pData; 
pCb->World               = world; 
pCb->ViewProjection      = viewProj; 

pCb->WorldViewProjection = world * viewProj;

那么仅仅是这么一个临时变量保存的操作,就可以节省一半的时钟周期。再一个就是尽可能的避免切换两个不同的常量缓冲区,或者一个常量缓冲区只绑定一个着色器阶段,提防常量缓冲区no hit的开销。


最后,避免在每一帧进行大量的绘制调用以免图形驱动程序耗尽重命名空间,进而导致游戏卡顿。

为何有此一说?因为应用程序想要改变常量缓冲区的内容,但是通常仍然需要当前旧的内容,因为GPU仍然在访问或者即将需要访问他们。图形驱动程序为了避免停顿,图形驱动程序会为Map()/ Unmap()调用对或UpdateSubresource()的调用的每一次调用返回一个指向不同的内存块的指针。驱动程序使用一定量的额外内存来进行常量缓冲区重命名,而重命名的操作累计内存可能会增大,从而超出其重命名空间限制而导致卡顿。


题外话

对于大小小于64kb的缓冲区而言,使用常量缓冲区比结构化缓冲区性能要好,unity的gup instance使用的是常量缓冲区来实现的(这也是我猜想Unity为何使用Constant Buffer的原因吧),而且目前他有一个宏的限制UNITY_MAX_INSTANCE_COUNT 是500,也就是一次绘制调用最多是500个实例,可以猜测,其在C++中的结构定义应该是130字节左右,而130个字节的长度,足以放置一些矩阵啊,位置啊,缩放,uv之类的属性了。同时就我上篇所说,其实大可不必要的担心说这些会导致内存占用过大,往往你一张1024*1024的贴图就比他大了n倍了,再则,细分目前来说对桌面端都不提倡,更何况移动端(保守的说,未来五年应该都不会使用),而且像虚幻在他的官方文档就性能这一块来说都是建议关闭曲面细分的。


这里有一个比较有意思的话题([Fastest way to update a constant buffer per draw call](gamedev.net/forums/topi) 这是我在一个论坛看到的),大体是这么回事,有个人想渲染一些相同的东西,进行多次绘制,而着色器使用同一个常量缓冲区,如何更新这个常量缓冲区能够达到更好的性能。


其实这个有点类似于Gpu Instance,我们根据当前位置,动态的渲染一些周围的植被系统;目前所知的,调用Graphics.DrawMeshInstanced这个API几次应该是毫无压力的,具体Unity工程师对于这种情况用的是一个大的常量缓冲区呢,还是每个调用一个常量缓冲区就不知了。对于一个对象被绘制多次,其相对应的Constant Buffer应该就有多个个,例如:

cbuffer obj 
{
   float4 position;
   float  r;
};

当绘制n个时,应该就存在n个这样的Constant Buffer。当然这个也有64kb的限制,也就是说你一次绘制应该就只能绘制64kb/sizeof(obj),这里的计算是必须padding的,也就是128位对其(貌似DirectX 12已经是256位对其了)。如果一次常量缓冲区填满了还有未完成的绘制,那么其将继续进行Map()/UnMap()和Draw call()之类的工作,直到其全部绘制完成。


喜欢就点个赞再走嘛!欢迎关注我们的公众号哦!

编辑于 2018-04-19