Inside Geometry Instancing(上)
Inside Geometry Instancing(上)
翻译:clayman
clayman_joe@yahoo.com.cn
仅供个人学习使用,勿用于任何商业用途,转载请注明作者^_^
注:呵呵,发现我对翻译东西上瘾了。这次翻译了《GPU Gem2》中第三章的内容,大家共同学习^_^
在交互式程序中,丰富用户体验的重要方法之一就是呈现一个充满大量各种有趣物体的世界。从数不清的草丛、树木到普通杂物:所有这些都能提高画面最终的效果,让用户保持“幻想状态(suspension of disbelief)”。只有用户相信并且融入了这个世界,才会对这个世界充满感情——这就是游戏开发的圣杯(Holy Grail)。
从渲染的观点来看,实现这种效果,无非就是渲染大量小物体,一般情况下,这些物体彼此都很类似,只在颜色、位置以及朝向上有细小的差别。举个例子,比如森林中所有树的几何形状都是很类似的,而在颜色和高度上有很大差别。对用户来说,由外形各异的树组成的森林才真实,才会相信它,从而丰富自己的游戏体验。
但是,使用当前的GPU和图形库渲染大量由少量多边形组成的小物体会带来很大的性能损失。诸如Direct3D和OpenGL之类的图形API都不是为了每帧渲染只有少数多边形的物体数千次而设计的。本文将讨论如何使用Direct3D把同一几何体渲染为大量独特的实体(instances)。下图是Back & White 2中,使用了这一技术的一个例子:
3.1 为何使用Geometry Instancing (Why Geometry Instancing)
在Direct3D中,把三角形数据提交给GPU是一个相对很慢的操作。Wloka 2003显示使用Direct3D在1GHz的CPU上,每秒只能渲染10000到400000批次(batches)。对于现代的CPU,可以预测这个值大概在每秒30000到120000批次之间(对FPS为30frame/sec系统来说大概每帧1000到4000批次)。这太少了!这意味着如果我要渲染一片森林,每批次提交一颗树的数据,那么无论每棵树包含多少多边形,都将无法渲染4000棵树以上——因为CPU已经没有时间来处理其他任务了。这种情况当然是我们不想看到的。在应用程序中,我们希望最小化渲染状态和纹理的改变,同时,在Direct3D中使用一次方法调用,在同一批次中对同一三角形进行多次渲染。这样,就能减少CPU提交批次的时间,把CPU资源留给物理、AI等其他系统。
3.2 定义(Definitions)
我们先来定义一系列与geometry instancing相关的概念。
3.2.1 几何包(Geometry Packet)
A geometry packet is a description of a packet of geometry to be instanced, a collection of vertices and indices。一个几何包可以使用顶点——包括他的位置、纹理坐标、法线、切线空间(tangent space)以及用于skinning的骨骼信息——以及顶点流中的索引信息来描述。这样的描述,可以直接映射为一个高效的提交几何体的方法。
几何包是对一个几何体在模型空间进行的抽象描述, 从而可以独立于当前的渲染环境。
下面是对几何包的一种可能的描述,它不但包含了几何体的信息,同时还包含了物体的边界球体信息:
struct GeometryPacker
{
Primitive mPrimType;
void* mVertice;
unsigned int mVertexStride;
unsigned short* mIndices;
unsigned int mVertexCount;
unsigned int mIndexCount;
D3DXVECTOR3 mSphereCentre;
float mSphereRadius;
}
3.2.2 实体属性(Instance Attribute)
对每个实体来说,典型的属性包括模型到世界的坐标变换矩阵,实体颜色以及由animation player提供的用于对几何包进行skin的骨骼。
struct InstanceAttributes
{
D3DXMATRIX mModelMatrix;
D3DCOLOR mInstanceColor;
AnimationPlayer* mAnimationPlayer;
unsigned int mLOD;
}
3.2.3 几何实体(Geometry Instance)
几何实体就是一个几何包与特定属性的集合。他直接联系到一个几何包以及一个将要用于渲染的实体属性,包含了将要提交给GPU的关于实体的完整描述。
struct GeometryInstance
{
GeometryPacket* mGeometryPacket;
InstanceAttributes mInstanceAttributes;
}
3.2.4 渲染及纹理环境(Render and Texture Context)
渲染环境指的是当前的GPU渲染状态(比如alpha blending, testing states, active render target等等)。纹理环境指的则是当前激活(active) 的纹理。通常使用类来对渲染状态和纹理状态进行模块化。
class RenderContext
{
public:
//begin the render context and make its render state active
void Begin(void);
//End the render context and restore previous render states if necessary
void End(void);
private:
//Any description of the current render state and pixel and vertex shaders.
//D3DX Effect framework is particularly useful
ID3Deffect* mEffect;
//Application-specific render states
//….
};
class TextureContext
{
public:
//set current textures to the appropriate texture stages
void Apply(void) const;
private :
Texture mDiffuseMap;
Texture mLightMap;
//……..
}
3.2.5 几何批次(Geometry Batch)
几何批次是一系列几何实体的集合,以及用来渲染这个集合的渲染状态和纹理环境。为了简化类的设计,通常直接映射为一次DrawIndexedPrimitive()方法调用。以下是几何批次类的一个抽象接口:
class GeometryBatch
{
public:
//remove all instances form the geometry batch
virtual void ClearInstances(void);
//add an instance to the collection and return its ID. Return -1 if it can’t accept more instance.
virtual int AddInstance(GeometryInstance* instance);
//Commit all instances, to be called once before the render loop begins and after every change to the instances collection
virtual unsigned int Commit(void) = 0;
//Update the geometry batch, eventually prepare GPU-specific data ready to be submitted to the driver, fill vertex and
//index buffers as necessary , to be called once per frame
virtual void Update(void) = 0;
//submit the batch to the driver, typically impemented eith a call to DrawIndexedPrimitive
virtual void Render(void) const = 0;
private:
GeometryInstancesCollection mInstances;
}
3.3 实现(Implementation)
引擎的渲染器只能通过GeometryBatch的抽象接口来使用geometry instancing,这样能很好隐藏具体的实体化(instancing)实现,同时,提供管理实体、更新数据、以及渲染批次的服务。这样引擎就能集中于分类(sorting)批次,从而最小化渲染和纹理状态的改变。同时,GeometryBatch完成具体的实现,并且与Direct3D进行通信。
下面使用的伪代码实现了一个简单的渲染循环:
//Update phase
Foreach GeometryBatch in ActiveBatchesList
GeometryBatch.Update();
//Render phase
Foreach RenderJContext
Begin
RenderContext.BeginRendering();
RenderContext.CommitStates();
Foreach TextureContext
Begin
TextureContext.Apply();
Foreach GeometryBatch in the texture context
GeometryBatch.Render();
End
End
为了能一次更新所有批次并且进行多次渲染,更新和渲染阶段应该分为独立的两部分:这种方法在渲染阴影贴图或者水面的反射以及折射时特别有用。这里我们将讨论4种GeometryBatch的实现,并且通过比较内存占用量、可控性来分析各种技术的性能特性。
这里是一个大概的摘要:
l 静态批次(static batching):执行instance geometry最快的方法。每个实体通过一次变换移动到世界坐标,附加上属性值,然后就提交给GPU。静态批次很简单,但也是可控性最小的一种。
l 动态批次(Dynamic batching):执行insance geometry最慢的方法。每一帧里,每个经过变换,附加了属性的实体都以流的形式传入GPU。动态批次可以完美的支持skinning,也是可控性最强的。
l Vertex constants instancing:一种混合的实现方法。每个实体的几何信息都被复制多次,并且一次性把他们复制到GPU的缓存中。通过顶点常量,每一帧都重新设置实体属性,使用一个vertex shader完成gemetry instancing。
l Batching with Geometry Instancing API。使用DirectX 9提供的Geometry Instancing API,可以获得GeForce 6系列显卡完全的硬件支持,这是一种高效而又具有高度可控性的gemetry instancing方法。与其他几种方法不同的是它不需要把几何包复制到Direct3D的顶点流中。
3.3.1 静态批次(Static Batching)
对静态批次来说,我们希望对所有实体进行一次变换之后,复制到一块静态顶点缓冲中。静态批次最大的优点就是高效,同时几乎市场上所有的GPU都能支持这个特性。
为了实现静态批次,先创建一个用来填充经过变化后的几何体的顶点缓冲对象(当然也包括索引缓冲)。需要保证这个这个缓冲足够大,足以储存我们希望处理的所有实体。由于我们只对缓冲进行一次填充,并且不再做修改,因此,可以使用Direct3D中的D3DUSAGE_WRITEONLY标志,提示驱动程序把缓冲放到速度最快的可用显存中:
HRESULT res;
res = lpDevice -> CreateVertexBuffer( MAX_STATIC_BUFFER_SIZE, D3DUSAGE_WRITE, 0, D3DPOOL_MANAGED, &mStaticVertexStream, 0 );
ENGINE_ASSERT(SUCCEEDED(res));
根据应用程序的类型或者引擎的内存管理方式,可以选择使用D3DPOOL_MANAGED或D3DPOOL_DEFAULT标志来创建缓冲。
接下来实现Commit()方法。它将把需要渲染的经过坐标变换的几何体数据填充到顶点和索引缓冲中。以下是Commit方法的伪代码实现:
Foreach GeometryInstance in Instances
Begin
transform geometry in mGeometryPack to world space with instance mModelMatrix
Apply other instnce attributes(like instace color)
Copy transformed geometry to the Vertex Buffer
Copy indices ( with the right offset) to the Index Buffer
Advance current pointer to the Vertex Buffer
Advance currect pointer to the Index Buffer
End
好了,接下来就只剩使用DrawIndexedPrimitive()方法,提交这些准备好的数据了。Update()方法和Render()方法的实现都很简单,这里不具体讨论。
静态批次是渲染大量实体最快的方法,它可以在一个批次中包含不同类型的几何包,但也有一些严重的限制:
l 大内存占用(Large memory footprint):根据几何包大小和希望渲染的实体数量,内存占用量可能会变的很大。对于大场景来说,应该预留出几何体所需的空间。Falling back to AGP memory is possible(注:这里应该指的是当显存不够用时,需要把数据分页存放到AGP memory中),但这会降低效率,因此,应该尽量避免。
l 不支持多种LOD(No support for different level of detal):由于在提交数据时,所有实体都被一次性复制到顶点缓冲中,因此很难对每种环境都选择一个有效的LOD层次,同时,还会导致对多边形数量的预算不正确。可以使用一种半静态的方法来解决这个问题,把特定实体的所有LOD层次都放在顶点缓冲中,每一帧选择不同的索引值,来选择实体的正确LOD。但这样会让实现看起来很笨拙,违反了我们使用这种方法最初的目的:简单并且高效。
l No support for skinning
l 不直接支持实体移动(No direct support for moving instances):由于效率的原因,实体的移动应该使用vertex shader逻辑和动态批次来实现。最终的解决方案其实就是vertex constants instancing。
接下来的一种方法将解除这些限制,以牺牲渲染速度换取可控性。
3.3.2 动态批次(DynamicBatching)
动态批次以降低渲染效率为代价,克服了静态批次方法的限制。动态批次最大的优点和静态批次一样,也能在不支持高级编程管道的GPU上使用。
首先使用D3DUSAGE_DYNAMIC和D3DPOOL_DEFAULT标志创建一块顶点缓冲(同样也包括相应的索引缓冲)。这些标志将保证缓冲处于最容易进行内存定位的地方,以满足我们动态更新的要求
HRESULT res;
res = lpDevice->CreateVertexBuffer(MAX_DYNAMIC_BUFFER_SIZE, D3DUSAGE_DYNAMIC | D3DUSAGE_WRITEONLY, 0 , D3DPOOL_DEFAULT, &mDynamicVertexStream, 0)
这里,选择正确的MAX_DYNAMIC_BUFFER_SIZE值是很重要的。有两种策略来选择这个值:
l 选择一个可以容纳每一帧里所有可能实体的足够大值。
l 选择一个足够大的值,以保证可以容纳一定量的实体。
第一种策略在一定程度上保证了更新和渲染批次的独立。更新批次意味着对动态缓冲中的所有数据进行数据流化(streaming);而渲染则只是使用DrawIndexedPrimitive()方法提交几何数据。当这种方法将会占用大量的图形内存(显存或者AGP memory),同时,在最差的情况下,这种方法将变的不可靠,因为我们无法保证缓冲在整个应用程序生命期中都足够大。
第二种策略则需要在几何体信息数据流化和渲染之间进行交错:当动态缓冲被填满时,提交几何体进行渲染,同时丢弃缓冲中的数据,准备好填充更多将被数据流化的实体。为了优化性能,使用正确的标志是很重要的,换句话说就是,在每一批实体开始时都使用D3DLOCK_DISCARD标志锁定(locking)动态缓冲,此外,对每个将要数据流化的新实体都使用D3DLOCK_WRITEONLY标志。这个方法的缺点是每次当批次需要进行渲染时,都需要重新锁定缓冲,以数据流化几何体信息,比如实现阴影影射时。
应该根据应用程序的类型和具体要求来选择不同方法。这里,由于简单和清楚的原因,我们选择了第一种方法,但是也添加了一点点复杂度:动态批次天生支持skinning,我们顺便对他进行了实现。
Update方法与之前在3.3.1讨论的Commit()方法很类似,但它需要在每一帧都执行。这里是伪代码的实现;
Foreach GeometryInstance in Instances
Begin
Transform geometry in mGeometryPacket to world space with instance mModelMatrix
if instance nedds skinning, request a set of bones from mAnimationPlayer and skin geometry
Apply other instance attributes(like instance color)
Copy transformd geometry to the Vertex Buffer
Copy indices (with the right offset) to the Index Buffer
Advance current pointer to the Vertex Buffer
Advance current pointer to the Index Buffer
End
这种情况下,Render()方法只是简单的调用DrawIndexedPrimitive()方法而已。