在D3D中,一共有三种基本图元,分别是点、线和三角形。点是最简单的图元,由它可以构成一种叫点列(point list)的图元类型。线是由两个不重合的点构成的,一些不相连的线组成的集合就叫线列(line list),而一些首尾相连但不形成环路的线的集合就叫线带(line strips)。同理,单独的三角形集合就叫三角形列(triangle list),类似于线带的三角形集合就叫三角形带(triangle strips),另外,如果多个三角形共用一个顶点作为它们的一个顶点的话,那么这个集合就叫三角形扇(triangle fans)。还是画图比较容易理解吧:
这些图元有什么用呢?基本上我们可以使用这些图元来画我们想要的任何物体。例如画一个四方形可以使用三角形带来画,画一个圆则使用三角形扇。
现在介绍一种不需要顶点缓冲来渲染的方法,就是使用IDirect3DDevice9::DrawPrimitiveUP函数。UP就是User Pointer的意思,也即是说要使用用户定义的内存空间。
HRESULT DrawPrimitiveUP(
D3DPRIMITIVETYPE PrimitiveType,
unsigned int PrimitiveCount,
const void *pVertexStreamZeroData,
unsigned int VertexStreamZeroStride
);
PrimitiveType:要绘画的图元的种类。就是上面介绍的那六种类型。
PrimitiveCount:要绘画的图元的数量。假设有n个顶点信息,绘画的图元类型是点列的话,那么图元的数量就是n;如果绘画的图元类型是线列的话,那么图元的数量就是n/2;如果是线带的话就是n-1;三角形列就是n/3;三角形带就是n-2;三角形扇出是n-2。
pVertexStreamZeroData:存储顶点信息的数组指针
VertexStreamZeroStride:顶点的大小
。使用顶点缓冲来绘画图元
很多时候我们使用顶点来定义图形之后,就把这些顶点信息放进顶点缓冲里面,然后再进行渲染。使用点顶缓冲的好处以及如何创建顶点缓冲我已经在上一章已讲过了,现在讲讲怎么把顶点缓冲里面的图元给画出来。其实也很简单,和上面的IDirect3DDevice9::DrawPrimitiveUP函数差不多,我们使用IDirect3DDevice9::DrawPrimitive函数。不过在使用这个函数之前,我们得告诉设备我们使用哪个数据源,使用IDirect3DDevice9::SetStreamSource函数可以设定数据源。
HRESULT SetStreamSource(
UINT StreamNumber,
IDirect3DVertexBuffer9 *pStreamData,
UINT OffsetInBytes,
UINT Stride
);
StreamNumber:设置和哪个数据流梆定。如果使用单数据流的话,这里设为0。最多支持16个数据流。
pStreamData:要绑定的数据。也就是我们创建的顶点缓冲区里面的数据。
OffsetInBytes:设置从哪个字节开始读起。如果要读整个缓冲区里面的数据的话,这里设为0。
Stride:单个数据元素的大小。如果数据源是顶点缓冲的话,那么这里就是每个顶点信息的大小(Sizeof(vertex))。
设置好数据源后,就可以使用IDirect3DDevice9::DrawPrimitive来绘画了。
HRESULT DrawPrimitive(
D3DPRIMITIVETYPE PrimitiveType,
unsigned int StartVertex,
unsigned int PrimitiveCount
);
PrimitiveType:要绘画的图元的种类。
StarVertex: 设置从顶点缓冲区中的第几个顶点画起。没有特殊情况当然是想把全部的顶点画出来啦,所以一般这里设置从0开始。
PrimitiveCount:要绘画的图元的数量。
最初只因DXSDK文档里说了句推荐用Vertex Buffer而不要用DrawPrimitiveUP(C#里叫DrawUserPrimitive),DrawPrimitiveUP很快被描绘成传说中的瘟疫,人人都在警告不要接近它。估计有人会想过,既然DrawPrimitiveUP这么不好,为什么还要提供它,难道只是为了显示DX也可以像OpenGL一样简单地画三角形?
记得当年我就是抱着这种想法在网上狂搜,功夫不负有心人,还真找到了。不过看了很不好意思,人家上来就批判不实际测试、以讹传讹的问题,我也比较懒,没动手测一下。那么为了和我一样的懒人,我把问题用中文解释一遍。
首先,DrawPrimitiveUP内部其实就是一个dynamic vertex buffer(动态顶点缓冲),和我们自己实现一个动态顶点缓冲没区别。一般情况下,DrawPrimitiveUP和用动态顶点缓冲的效率也没多大区别。也就是说DrawPrimitiveUP其实很高效的,而且简单易用。Irrlicht引擎几乎所有绘制都用的DrawPrimitiveUP,也很快的。
那为什么不推荐用?
原因一:DX8发布时显存容量已经有了很大提高,静态顶点缓冲可以缓存在显存或AGP内存里,从而节省带宽占用。所以推荐能用静态顶点缓冲的一定要用静态的。静态的可以比DrawPrimitiveUP和动态顶点缓冲都快很多。
原因二:DrawPrimitiveUP相对动态顶点缓冲而言,需要将用户内存里的顶点数据复制到内部动态顶点缓冲,即多了一次复制,如果顶点数量较大,复制开销也会加大。但很多程序里的动态缓冲设计并不太好,为了抽象或方便使用,也会复制一次数据。所以便丧失了这条优势。
原因三:这个比较复杂,我们知道一帧内Batch(批)的数量直接影响CPU的占用率,1G处理器30FPS下每帧700Batch左右就会占用100%CPU。每个设置设备状态到发出绘制命令的转换都将产生一个Batch。动态顶点缓冲的推荐使用模式是一个可以合并Batch的模式,即不断地填充顶点数据,但不立刻绘制,在缓冲填满时才提交绘制一次,当然能合并的前提是各个batch都使用相同的设备状态,即纹理、材质、RenderStates、变换矩阵等。
原因四:DrawPrimitiveUP只支持一个顶点流。这其实是个不算是原因的原因。当然是只用一个顶点流时才用它。
综上所述,这其实是个优化问题,用动态顶点缓冲有可能做更多的优化,但如果做得不好,会比DrawPrimitiveUP差。如果正确使用了,但没有进一步的优化或者引擎的用法不具备可优化的特性,那么也就和DrawPrimitiveUP效率相当。
但事实上用动态顶点缓冲做错了的也很多,最常见的就是没有正确使用Lock标志位,用锁定静态缓冲的方法锁定,根本得不到动态缓冲的效果。另外用C#和MDX的,如果用返回数组的Lock方法重载,也完全没有意义,因为在内部整个缓冲被复制到数组,Unlock时再复制回去。即使用GraphicsStream写顶点数据也很慢,因为会导致大量的Boxing,只有直接用指针写数据才能发挥动态缓冲的优势。
怎么才算做得好,DXSDK里有明确的样例,为了懒人,我帖出来:
// 用法 1
// 每次绘制抛弃整个顶点缓冲内容并重新填充几千个顶点
// 可能包含多个物体,有可能需要按设备状态分几次DrawPrimitive
// 计算需要填充的字节数
UINT nSizeOfData = nNumberOfVertices * m_nVertexStride;
// 抛弃并重新填充
CONST DWORD dwLockFlags = D3DLOCK_DISCARD;
// 锁定顶点缓冲内存
BYTE* pBytes;
if( FAILED( m_pVertexBuffer->Lock( 0, 0, &pBytes, dwLockFlags ) ) )
return false;
// 将顶点数据复制到顶点缓冲
memcpy( pBytes, pVertices, nSizeOfData );
m_pVertexBuffer->Unlock();
// 绘制
m_pDevice->DrawPrimitive( D3DPT_TRIANGLELIST, 0, nNumberOfVertices/3)
// 用法 2
// 对多个物体复用一个顶点缓冲
// 计算需要填充的字节数
UINT nSizeOfData = nNumberOfVertices * m_nVertexStride;
// 如果顶点缓冲内的剩余空间可以容纳要填充的顶点数量,则指定不覆盖原有数据
DWORD dwLockFlags = D3DLOCK_NOOVERWRITE;
// 检查顶点缓冲空间是否用光
if( m_nNextVertexData > m_nSizeOfVB - nSizeOfData )
{
// 没有足够的空间,抛弃原有数据重新开始
dwLockFlags = D3DLOCK_DISCARD;
m_nNextVertexData = 0;
}
// 锁定顶点缓冲内存
BYTE* pBytes;
if( FAILED( m_pVertexBuffer->Lock( (UINT)m_nNextVertexData, nSizeOfData,
&pBytes, dwLockFlags ) ) )
return false;
// 将顶点数据复制到顶点缓冲
memcpy( pBytes, pVertices, nSizeOfData );
m_pVertexBuffer->Unlock();
// 绘制
m_pDevice->DrawPrimitive( D3DPT_TRIANGLELIST,
m_nNextVertexData/m_nVertexStride, nNumberOfVertices/3)
// 计算下一次的写入位置
m_nNextVertexData += nSizeOfData;
当然,这只是个正确用法样例,优化起来可还是会面目全非的。如果你的D3D功夫不够剑豪剑圣级别,大可安心地用DrawPrimitiveUP,对付一些杂碎三角面,也大可不必杀鸡用牛刀,用DrawPrimitiveUP剁几下就行了。另外注意测试的时候一定要用硬件模式测,软件模式的结果是完全不同的。
对于顶点数据很少变化的情况下,不推荐使用DrawPrimitiveUP,但是在顶点数据经常变化的时候,或者对程序的性能要求不高的场合下,DrawPrimitiveUP是个不错的选择。实际上OpenGL默认的工作模式和DrawPrimitiveUP是一样的,也没见性能差多少。
我的GUI系统用的是DrawPrimitiveUP,因为顶点数据是动态更新的。
DrawPrimitiveUP的内部实现也是创建了一个动态的buffer,然后自动填充调用DrawPrimitive渲染,可以自己做是一样的,但是系统优化的更好。
有人做过测试,在GUI这种动态更新顶点数据的场合下,使用DrawPrimitiveUP的速度比使用普通的顶点缓冲区要快很多
这些图元有什么用呢?基本上我们可以使用这些图元来画我们想要的任何物体。例如画一个四方形可以使用三角形带来画,画一个圆则使用三角形扇。
现在介绍一种不需要顶点缓冲来渲染的方法,就是使用IDirect3DDevice9::DrawPrimitiveUP函数。UP就是User Pointer的意思,也即是说要使用用户定义的内存空间。
HRESULT DrawPrimitiveUP(
D3DPRIMITIVETYPE PrimitiveType,
unsigned int PrimitiveCount,
const void *pVertexStreamZeroData,
unsigned int VertexStreamZeroStride
);
PrimitiveType:要绘画的图元的种类。就是上面介绍的那六种类型。
PrimitiveCount:要绘画的图元的数量。假设有n个顶点信息,绘画的图元类型是点列的话,那么图元的数量就是n;如果绘画的图元类型是线列的话,那么图元的数量就是n/2;如果是线带的话就是n-1;三角形列就是n/3;三角形带就是n-2;三角形扇出是n-2。
pVertexStreamZeroData:存储顶点信息的数组指针
VertexStreamZeroStride:顶点的大小
。使用顶点缓冲来绘画图元
很多时候我们使用顶点来定义图形之后,就把这些顶点信息放进顶点缓冲里面,然后再进行渲染。使用点顶缓冲的好处以及如何创建顶点缓冲我已经在上一章已讲过了,现在讲讲怎么把顶点缓冲里面的图元给画出来。其实也很简单,和上面的IDirect3DDevice9::DrawPrimitiveUP函数差不多,我们使用IDirect3DDevice9::DrawPrimitive函数。不过在使用这个函数之前,我们得告诉设备我们使用哪个数据源,使用IDirect3DDevice9::SetStreamSource函数可以设定数据源。
HRESULT SetStreamSource(
UINT StreamNumber,
IDirect3DVertexBuffer9 *pStreamData,
UINT OffsetInBytes,
UINT Stride
);
StreamNumber:设置和哪个数据流梆定。如果使用单数据流的话,这里设为0。最多支持16个数据流。
pStreamData:要绑定的数据。也就是我们创建的顶点缓冲区里面的数据。
OffsetInBytes:设置从哪个字节开始读起。如果要读整个缓冲区里面的数据的话,这里设为0。
Stride:单个数据元素的大小。如果数据源是顶点缓冲的话,那么这里就是每个顶点信息的大小(Sizeof(vertex))。
设置好数据源后,就可以使用IDirect3DDevice9::DrawPrimitive来绘画了。
HRESULT DrawPrimitive(
D3DPRIMITIVETYPE PrimitiveType,
unsigned int StartVertex,
unsigned int PrimitiveCount
);
PrimitiveType:要绘画的图元的种类。
StarVertex: 设置从顶点缓冲区中的第几个顶点画起。没有特殊情况当然是想把全部的顶点画出来啦,所以一般这里设置从0开始。
PrimitiveCount:要绘画的图元的数量。
最初只因DXSDK文档里说了句推荐用Vertex Buffer而不要用DrawPrimitiveUP(C#里叫DrawUserPrimitive),DrawPrimitiveUP很快被描绘成传说中的瘟疫,人人都在警告不要接近它。估计有人会想过,既然DrawPrimitiveUP这么不好,为什么还要提供它,难道只是为了显示DX也可以像OpenGL一样简单地画三角形?
记得当年我就是抱着这种想法在网上狂搜,功夫不负有心人,还真找到了。不过看了很不好意思,人家上来就批判不实际测试、以讹传讹的问题,我也比较懒,没动手测一下。那么为了和我一样的懒人,我把问题用中文解释一遍。
首先,DrawPrimitiveUP内部其实就是一个dynamic vertex buffer(动态顶点缓冲),和我们自己实现一个动态顶点缓冲没区别。一般情况下,DrawPrimitiveUP和用动态顶点缓冲的效率也没多大区别。也就是说DrawPrimitiveUP其实很高效的,而且简单易用。Irrlicht引擎几乎所有绘制都用的DrawPrimitiveUP,也很快的。
那为什么不推荐用?
原因一:DX8发布时显存容量已经有了很大提高,静态顶点缓冲可以缓存在显存或AGP内存里,从而节省带宽占用。所以推荐能用静态顶点缓冲的一定要用静态的。静态的可以比DrawPrimitiveUP和动态顶点缓冲都快很多。
原因二:DrawPrimitiveUP相对动态顶点缓冲而言,需要将用户内存里的顶点数据复制到内部动态顶点缓冲,即多了一次复制,如果顶点数量较大,复制开销也会加大。但很多程序里的动态缓冲设计并不太好,为了抽象或方便使用,也会复制一次数据。所以便丧失了这条优势。
原因三:这个比较复杂,我们知道一帧内Batch(批)的数量直接影响CPU的占用率,1G处理器30FPS下每帧700Batch左右就会占用100%CPU。每个设置设备状态到发出绘制命令的转换都将产生一个Batch。动态顶点缓冲的推荐使用模式是一个可以合并Batch的模式,即不断地填充顶点数据,但不立刻绘制,在缓冲填满时才提交绘制一次,当然能合并的前提是各个batch都使用相同的设备状态,即纹理、材质、RenderStates、变换矩阵等。
原因四:DrawPrimitiveUP只支持一个顶点流。这其实是个不算是原因的原因。当然是只用一个顶点流时才用它。
综上所述,这其实是个优化问题,用动态顶点缓冲有可能做更多的优化,但如果做得不好,会比DrawPrimitiveUP差。如果正确使用了,但没有进一步的优化或者引擎的用法不具备可优化的特性,那么也就和DrawPrimitiveUP效率相当。
但事实上用动态顶点缓冲做错了的也很多,最常见的就是没有正确使用Lock标志位,用锁定静态缓冲的方法锁定,根本得不到动态缓冲的效果。另外用C#和MDX的,如果用返回数组的Lock方法重载,也完全没有意义,因为在内部整个缓冲被复制到数组,Unlock时再复制回去。即使用GraphicsStream写顶点数据也很慢,因为会导致大量的Boxing,只有直接用指针写数据才能发挥动态缓冲的优势。
怎么才算做得好,DXSDK里有明确的样例,为了懒人,我帖出来:
// 用法 1
// 每次绘制抛弃整个顶点缓冲内容并重新填充几千个顶点
// 可能包含多个物体,有可能需要按设备状态分几次DrawPrimitive
// 计算需要填充的字节数
UINT nSizeOfData = nNumberOfVertices * m_nVertexStride;
// 抛弃并重新填充
CONST DWORD dwLockFlags = D3DLOCK_DISCARD;
// 锁定顶点缓冲内存
BYTE* pBytes;
if( FAILED( m_pVertexBuffer->Lock( 0, 0, &pBytes, dwLockFlags ) ) )
return false;
// 将顶点数据复制到顶点缓冲
memcpy( pBytes, pVertices, nSizeOfData );
m_pVertexBuffer->Unlock();
// 绘制
m_pDevice->DrawPrimitive( D3DPT_TRIANGLELIST, 0, nNumberOfVertices/3)
// 用法 2
// 对多个物体复用一个顶点缓冲
// 计算需要填充的字节数
UINT nSizeOfData = nNumberOfVertices * m_nVertexStride;
// 如果顶点缓冲内的剩余空间可以容纳要填充的顶点数量,则指定不覆盖原有数据
DWORD dwLockFlags = D3DLOCK_NOOVERWRITE;
// 检查顶点缓冲空间是否用光
if( m_nNextVertexData > m_nSizeOfVB - nSizeOfData )
{
// 没有足够的空间,抛弃原有数据重新开始
dwLockFlags = D3DLOCK_DISCARD;
m_nNextVertexData = 0;
}
// 锁定顶点缓冲内存
BYTE* pBytes;
if( FAILED( m_pVertexBuffer->Lock( (UINT)m_nNextVertexData, nSizeOfData,
&pBytes, dwLockFlags ) ) )
return false;
// 将顶点数据复制到顶点缓冲
memcpy( pBytes, pVertices, nSizeOfData );
m_pVertexBuffer->Unlock();
// 绘制
m_pDevice->DrawPrimitive( D3DPT_TRIANGLELIST,
m_nNextVertexData/m_nVertexStride, nNumberOfVertices/3)
// 计算下一次的写入位置
m_nNextVertexData += nSizeOfData;
当然,这只是个正确用法样例,优化起来可还是会面目全非的。如果你的D3D功夫不够剑豪剑圣级别,大可安心地用DrawPrimitiveUP,对付一些杂碎三角面,也大可不必杀鸡用牛刀,用DrawPrimitiveUP剁几下就行了。另外注意测试的时候一定要用硬件模式测,软件模式的结果是完全不同的。
对于顶点数据很少变化的情况下,不推荐使用DrawPrimitiveUP,但是在顶点数据经常变化的时候,或者对程序的性能要求不高的场合下,DrawPrimitiveUP是个不错的选择。实际上OpenGL默认的工作模式和DrawPrimitiveUP是一样的,也没见性能差多少。
我的GUI系统用的是DrawPrimitiveUP,因为顶点数据是动态更新的。
DrawPrimitiveUP的内部实现也是创建了一个动态的buffer,然后自动填充调用DrawPrimitive渲染,可以自己做是一样的,但是系统优化的更好。
有人做过测试,在GUI这种动态更新顶点数据的场合下,使用DrawPrimitiveUP的速度比使用普通的顶点缓冲区要快很多