翻译:丁欧南
这是我第一次尝试翻译,不当之处请您不吝指教.转载请注明出处.
在3D程序中资源处理的快慢对程序的效率有着很大一部分的影响,记得我曾在不少人的Blog上看到他们写到只是由于改变了资源的处理方法,就使FPS数倍地增加.这给了我很深的印象.于是,我翻译了这篇FAQ中与此相关的部分,希望能给您以帮助.
英文版出处: http://members.gamedev.net/jhoxley/directx/DirectXForumFAQ.htm
D3D #13 资源分配惯例
Direct3D程序对各种资源的使用量都很大,这在存贮空间相对有限的情况下,有效地利用资源就显得甚为重要.(在这其中,首当其冲的就是纹理资源消耗巨大的问题.)
对于大多数软件而言,分配/释放资源是一种相对昂贵的操作.对于执行效率的渴求程度上升到实时(real-time)的3D程序来讲,尽量不在核心渲染作业中掺杂资源处理代码是相当明智的.在理想的情况下,所有的资源都应该在程序开始处完成分配,然后在核心渲染处使用,最终在程序结束时释放.这样一种”分配-使用-释放”的作业流程的观念应当被推广,任何一种资源的分配/释放操作都应以类似的思路去实践. 举例来说,在一个游戏的载入初始画面时执行资源的分配操作.
除非由于资源本身的性质有限制,那么最好在程序开始处就分配一整批资源(pool),然后在以后使用.这样就可以避免在真正渲染时才去处理资源.
微观的资源管理作业是困难的:也就是使用IDirect3DDevice9::GetAvailableTextureMem()来判断资源的可用量,因为这样得出的结果很少精确地与显卡标示出的显示内存数目(比如128MB,256MB,512MB等等…)匹配.所以你只能把这项功能当作一个参考.
如果你的纹理贴图因为包含了所有的Mip-Map细节层次而导致内存紧张,那么你可以尝试使用D3DX_SKIP_DDS_MIP_LEVELS() macro,一些D3DX的函数(比如D3DXCreateTextureFromFileEx())在这个macro的指定下就不会载入占用大量内存的高细节纹理层.这在你的程序里一种简单的应用方法就是,通过一个表达细节层次的数值,来决定使用哪种层次的纹理,比如,”high detail”不略过任何纹理层次;”medium detail”略过两个层次;”low detail”略过四个层次.
Direct3D的资源按照管理方法的不同可以被划分为4种规格,在这其中,D3DPOOL_MANAGED和D3DPOOL_DEFAULT是最常用到的.根据一般得到的经验,最好把所有的资源都分配在D3DPOOL_MANAGED里,除非有特别需求要把资源放在D3DPOOL_DEFAULT里(比如说,render-target就有这样的要求).放在D3DPOOL_MANAGED里的资源被Direct3D runtime 管理,这些资源只有在频繁使用时会被Direct3D runtime放到显示内存里,方便GPU快速取用.Direct3D runtime很少会引起明显的性能问题,但如果性能分析报告(profile)显示这种性能瓶颈确实存在于此,那么人工地把关键资源转而放在D3DPOOL_DEFAULT里也会是一种解决方案(这样资源就会呆在显示内存里,GPU可以快速取用).
这里面还有一个小技巧:就是使用D3DQUERYTYPE_RESOURCEMANAGER query(你可以查看DirectX SDK Document来获取有关它的信息)来查询.这个查询只能在debug runtime方式下使用,因此在最后的实际产品中有非常有限的用处,但它在开发过程中确实能够提供有价值的参考(甚至灵感).这个查询能给你一个关于内存使用的概况(查看DirectX SDK Document中的D3DRESOURCESTATS),使你知道纹理等数据在渲染流程中是怎样被Direct3D runtime传送于system RAM和VRAM(显存)之间的.使用这个查询取得的信息,并参考其它的性能分析报告(profile),你就能得知瓶颈是否存在于资源处理上.
SetLOD(),SetPriority(),PreLoad()的用处较小,但它们能够影响资源管理作业的方式.你能使用它们人为地加大一些游戏中重要元素的权重(比如说,标志牌,贴图,及其它高细节的纹理),或是降低背景等一些对细节要求不是很高的纹理的重要性.即使Direct3D runtime的自动资源管理能力再强大,它也不具备一名游戏开发者对于全局的把握.
另外一个使用D3DPOOL_MANAGED的好处是,可以避免device-lost带来的麻烦.不仅仅体现在它可以使编码简单(memory leak将更少见,重新分配资源的编码也将更少),它还会更加快速(否则重新载入操作将是一个费时的工作).
最好在D3DPOOL_MANAGED资源分配之前分配基于D3DPOOL_DEFAULT的资源,这样就能确保必须使用VRAM的资源能够率先得到足够的空间,而不至于VRAM先被D3DPOOL_MANAGED的资源填满.因为D3DPOOL_MANAGED在VRAM不够用的情况下进行的分配操作不会失败(在正式使用之前它们可以呆在system/AGP RAM里),但D3DPOOL_DEFAULT却不然.
D3D #14 加速资源的锁定操作(locking)
在Direct3D 程序设计中这个操作的执行次数非常得频繁.任何类似LockRect(),Lock()这样的调用(一般是作用于纹理,表面,以及顶点/索引缓存)都属于资源锁定.在你试图读取/写入存放于缓存中的数据时,你就会执行这项操作.它在许多算法实现中都有应用.
现在关键的问题就在于,资源锁定的操作速度总是非常之慢.当然,你会跟我提, OpenGL似乎能够快捷地完成这项任务.但是,在Direct3D中,资源锁定操作确实是很慢的.这里面一个主要的原因是,API,驱动,以及硬件要处理一些不可回避的后台操作.那就是GPU与CPU是并行运行的,若不加任何措施,将引起类似多线程程序同步时的竞态条件的问题.
如果你试图去修改的资源正同时被一个位于GPU处理序列中的指令使用,那么整个渲染流程就会因为你的资源锁定而停顿或强制刷新(stalls and flushes).停顿(stall)会一直持续到你完成了对资源的修改并调用Unlock().而强制刷新(flush)则会要求GPU在你得到这个资源的访问权之前完成目前所有的任务.
锁定是一种阻塞(Blocking)的操作---当你不巧调用Lock()锁定了一个当前还不能立即访问资源时,就会导致CPU停下手里的工作并一直等到这个资源可用为止.这样确实有效地同步了两个处理核心(GPU&CPU),但它却降低了整个程序的效率.
因为CPU不能直接访问位于显存中的地址,因此,显卡驱动程序需要把你要求的数据传送到CPU可寻址的RAM中去.如果你所要求的数据量非常之大,那么这项操作将是很漫长的,而且这项操作必须在API交给你控制权之前完成.届时还将引起敏感的连锁反应,程序中的阻塞代码以及AGP/PCI-E总线会使你的程序处于完全停顿之中,所有后续操作都将被搁置.这给程序的执行效率造成了严重的伤害.
经过上面的解释之后,最终就是强调一点,锁定操作是很慢的.大多数情况下祸端不是由带宽引起(指的是VRAM到System RAM的传送 –译注),而是源于阻塞所带来的延迟.不过,对于程序初始化流程中的资源锁定不用担心,它们不会给你带来太多麻烦.但,尽量减少锁定操作终归是一个好习惯.混迹于核心渲染代码之中的资源锁定必会使你焦头烂额.
话说回来,如果你不得不往程序的主循环体中加入资源操作代码怎么办呢?这里有一些小技巧,但不要局限于此,这是一件需要聪明的编程技巧的活.
首先,确保你在分配资源(Create*)时使用的标识符(flags)适合你的需要(参看D3DUSAGE)---这里面有很多选择并且你必须指定它们.当你需要锁定一个资源,请确保调用Lock()时指明的标识符适合你的需要(参看 D3DLOCK)---你将通过这些标识符把额外的一些有助于优化的信息传递给硬件,尽你所能帮助你的硬件完成优化又是一个很好的习惯.在这其中一个实际的例子就是动态资源(“dynamic resources”),Direct3D SDK的文档有专门的两节是有关这个论题的:[Using Dynamic Textures]和[Using Dynamic Vertex and Index Buffers].如果你在这些方面犯了错误,Direct3D的debug runtime会提醒你.---一定确保你认真思考了以上几点.
综上所讲,你锁定一个资源的时间(一般就是你在Lock()和Unlock()之间的代码的执行时间)反应了你使渲染流程停滞的严重程度.在一组Lock(),Unlock()之中完成所有的资源操作看起来是一种相当直观的做法,但它却不是最有效率的.这种做法只有当你要做的操作很小或者要同时执行读写时才去考虑.
如果你只是想读取数据,你可以使用非常快速的memcpy_s函数把已经锁定的数据复制到本地系统内存中,解锁,然后再去处理数据.这样做的好处就是在你处理数据的同时,同步执行的流水线并没有闲着,它还在渲染.与此相类似,如果你只想写入数据,也是应当利用memcpy_s把一大块(chunk)本地系统内存中的数据复制到VRAM中.如果你是想读出数据,然后处理,最后写回VRAM,你还是会发现分开的两次锁定(一次用来读取,一次用来写入)有可能会比单独的一次长时间的Lock()要好.
1
2 // Compute the number of elements in this vertex buffer...
3 D3DVERTEXBUFFER_DESC pDesc;
4 m_pVertexBuffer -> GetDesc( & pDesc );
5
6 size_t ElementCount = pDesc.Size / sizeof ( TerrainVertex );
7
8 // Declare the variables
9 void * pRawData = NULL;
10 TerrainVertex * pVertex = new TerrainVertex[ ElementCount ];
11
12 // Attempt to gain the lock
13 if ( SUCCEEDED( m_pVertexBuffer -> Lock( 0 , 0 , & pRawData, D3DLOCK_READONLY ) ) )
14 ...{
15 // Copy the data
16 errno_t err = memcpy_s( reinterpret_cast < void * > ( pVertex ), pDesc.Size, pRawData, pDesc.Size );
17
18 // Unlock the resource
19 if ( FAILED( m_pVertexBuffer -> Unlock( ) ) )
20 ...{
21 // Handle the error appropriately...
22 SAFE_DELETE_ARRAY( pVertex );
23 }
24
25 // Make sure the copy succeeded
26 if ( 0 == err )
27 ...{
28 // Work with the data...
29
30 // Clean-up
31 SAFE_DELETE_ARRAY( pVertex );
32 }
33
34 }
35 else
36 ...{
37 // Clean-up the allocated memory
38 SAFE_DELETE_ARRAY( pVertex );
39 }
40
现在再来考虑一种叫做轮换缓存的东西(bounded-buffer,或者叫ring buffer):把一个资源复制3份,比如说3个渲染目标(render target)或顶点缓存(vertex buffer),并在数据操作中从它们之间不断的循环,这样,你就可以在修改数据的同时,使流水线去渲染另外一份数据的副品,而不至于停顿.但这样做的不利一面就是你可能会得到一种扭曲的图像(这种情况大概就是GPU渲染的前半部分数据是未修改的,但后半部分却是修改后的 –译注),并且对于某些无法分离开的操作流程你也无法使用这种 修改-渲染 不同内存位置的技术.
1
2 // Declarations
3 DWORD dwBoundedBufferSize = 4 ;
4 DWORD dwCurrentBuffer = 0 ;
5 LPDIRECT3DSURFACE9 * pBoundedBuffer = new LPDIRECT3DSURFACE9[ dwBoundedBufferSize ];
6
7 // Create the resources
8 for ( DWORD i = 0 ; i < dwBoundedBufferSize; i ++ )
9 ...{
10 if ( FAILED( pd3dDevice -> CreateRenderTarget( ..., & pBoundedBuffer[i], ... ) ) )
11 ...{
12 // Handle error condition here..
13 }
14 }
15
16 // On this frame we should render to 'dwIndexToRender'
17 DWORD dwIndexToRender = dwCurrentBuffer;
18
19 // We should lock 'dwCurrentBuffer + 1' - which will be the
20 // oldest of the available buffers, thus hopefully not in the command queue.
21 DWORD dwIndexToLock = (dwCurrentBuffer + 1 ) % dwBoundedBufferSize;
22
23 // At the end of each frame we make sure to move the index forwards:
24 dwCurrentBuffer = (dwCurrentBuffer + 1 ) % dwBoundedBufferSize;
25
26 // Release the resources
27 for ( DWORD i = 0 ; i < dwBoundedBufferSize; i ++ )
28 SAFE_RELEASE( pBoundedBuffer[i] );
29
30 SAFE_DELETE_ARRAY( pBoundedBuffer );
31
如果你要修改的数据确实非常大,请你考虑错开上传/下载数据的时机,比如花费10桢的时间完成,每次10%,把数据不断加入上次上传的数据末端.这样做的原始意图还是增大Lock()-Unlock()的间隔,增大数据的可用率(被流水线利用-译注).虽然这样做并不总是有效果,但至少还是值得考虑的.
就像早先指出的,锁定操作会影响CPU-GPU的协作效率,所以你希望尽可能少的锁定次数.如果有大量数据需要处理,考虑分而治之.这样程序的效率便不会有显著的下降.一种可能的实作技术就是维护一个保存了待执行命令的队列,一桢只执行1个(或2,3…个),而不管其后还有多少命令在等待.