DirectX11 With Windows SDK--20 硬件实例化与视锥体裁剪
前言
注意:这一章对应项目代码2.x.x版本,持有低版本项目代码的同学请到Github更新一份
这一章将了解如何在DirectX 11利用硬件实例化技术高效地绘制重复的物体,以及使用视锥体裁剪技术提前将位于视锥体外的物体进行排除。
在此之前需要额外了解的章节如下:
章节回顾 |
---|
18 使用DirectXCollision库进行碰撞检测 |
19 模型加载:obj格式的读取及使用二进制文件提升读取效率 |
CPU与GPU计时器 |
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。
硬件实例化(Hardware Instancing)
硬件实例化指的是在场景中绘制同一个物体多次,但是是以不同的位置、旋转、缩放、材质以及纹理来绘制(比如一棵树可能会被多次使用以构建出一片森林)。在以前,每次实例绘制(Draw方法)都会引发一次顶点缓冲区和索引缓冲区经过输入装配阶段传递进渲染管线中,大量重复的绘制则意味着多次反复的输入装配操作,会引发十分庞大的性能开销。事实上在绘制同样物体的时候顶点缓冲区和索引缓冲区应当只需要传递一次,然后真正需要多次传递的也应该是像世界矩阵、材质、纹理等这些可能会经常变化的数据。
要能够实现上面的这种操作,还需要图形库底层API本身能够支持按对象绘制。对于每个对象,我们必须设置它们各自的材质、世界矩阵等,然后才是调用绘制命令。尽管在Direct3D 10和后续的版本已经将原本Direct3D 9的一些API重新设计以尽可能最小化性能上的开销,部分多余的开销仍然存在。因此,Direct3D提供了一种机制,不需要通过API上的额外性能开销来实现实例化,我们称之为硬件实例化。
为什么要担忧API性能开销呢?Direct3D 9应用程序通常因为API导致在CPU上遇到瓶颈,而不是在GPU。以前关卡设计师喜欢使用单一材质和纹理来绘制许多对象,因为对于它们来说需要经常去单独改变它的状态并且去调用绘制。场景将会被限制在几千次的调用绘制以维持实时渲染的速度,主要在于这里的每次API调用都会引起高级别的CPU性能开销。现在图形引擎可以使用批处理技术以最小化绘制调用的次数。硬件实例化是API帮助执行批处理的一个方面。
顶点与实例数据的组合 与 流式实例化数据
现在,我们需要着重观察D3D11_INPUT_ELEMENT_DESC
的最后两个成员:
typedef struct D3D11_INPUT_ELEMENT_DESC
{
LPCSTR SemanticName; // 语义名
UINT SemanticIndex; // 语义名对应的索引值
DXGI_FORMAT Format; // DXGI数据格式
UINT InputSlot; // 输入槽
UINT AlignedByteOffset; // 对齐的字节偏移量
D3D11_INPUT_CLASSIFICATION InputSlotClass; // 输入槽类别(顶点/实例)
UINT InstanceDataStepRate; // 实例数据步进值
} D3D11_INPUT_ELEMENT_DESC;
1.InputSlotClass
:指定输入的元素是作为顶点元素还是实例元素。枚举值含义如下:
枚举值 | 含义 |
---|---|
D3D11_INPUT_PER_VERTEX_DATA | 作为顶点元素 |
D3D11_INPUT_PER_INSTANCE_DATA | 作为实例元素 |
2.InstanceDataStepRate
:指定每份实例数据绘制出多少个实例。例如,假如你想绘制6个实例,但提供了只够绘制3个实例的数据,1份实例数据绘制出1种颜色,分别为红、绿、蓝。那么我们可以设置该成员的值为2,使得前两个实例绘制成红色,中间两个实例绘制成绿色,后两个实例绘制成蓝色。通常在绘制实例的时候我们会将该成员的值设为1,保证1份数据绘制出1个实例。对于顶点成员来说,设置该成员的值为0.
在前面的例子,我们知道一个结构体的数据可以来自多个输入槽,现在要使用硬件实例化,我们需要使用至少两个输入槽(其中至少一个顶点缓冲区,至少一个实例缓冲区)
现在我们需要使用的顶点与实例数据组合的结构体如下:
struct InstancePosNormalTex
{
float3 PosL : POSITION; // 来自输入槽0
float3 NormalL : NORMAL; // 来自输入槽0
float2 Tex : TEXCOORD; // 来自输入槽0
matrix World : World; // 来自输入槽1
matrix WorldInvTranspose : WorldInvTranspose; // 来自输入槽1
};
输出的结构体和以前一样:
struct VertexPosHWNormalTex
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION; // 在世界中的位置
float3 NormalW : NORMAL; // 法向量在世界中的方向
float2 Tex : TEXCOORD;
};
现在顶点着色器代码变化如下:
VertexPosHWNormalTex VS(InstancePosNormalTex vIn)
{
VertexPosHWNormalTex vOut;
matrix viewProj = mul(g_View, g_Proj);
vector posW = mul(float4(vIn.PosL, 1.0f), vIn.World);
vOut.PosW = posW.xyz;
vOut.PosH = mul(posW, viewProj);
vOut.NormalW = mul(vIn.NormalL, (float3x3) vIn.WorldInvTranspose);
vOut.Tex = vIn.Tex;
return vOut;
}
至于像素着色器,和上一章为模型所使用的着色器的保持一致。
对于前面的结构体InstancePosNormalTex
,与之对应的输入成员描述数组如下:
D3D11_INPUT_ELEMENT_DESC basicInstLayout[] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "World", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 0, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "World", 1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 16, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "World", 2, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 32, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "World", 3, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 48, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "WorldInvTranspose", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 64, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "WorldInvTranspose", 1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 80, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "WorldInvTranspose", 2, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 96, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "WorldInvTranspose", 3, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 112, D3D11_INPUT_PER_INSTANCE_DATA, 1}
};
因为DXGI_FORMAT
一次最多仅能够表达128位(16字节)数据,在对应矩阵的语义时,需要重复描述4次,区别在于语义索引为0-3.
顶点的数据占用输入槽0,而实例数据占用的则是输入槽1。这样就需要我们使用两个缓冲区以提供给输入装配阶段。其中第一个作为顶点缓冲区,而第二个作为实例缓冲区以存放有关实例的数据,绑定到输入装配阶段的方法如下:
struct VertexPosNormalTex
{
DirectX::XMFLOAT3 pos;
DirectX::XMFLOAT3 normal;
DirectX::XMFLOAT2 tex;
static const D3D11_INPUT_ELEMENT_DESC inputLayout[3];
};
struct InstancedData
{
XMMATRIX world;
XMMATRIX worldInvTranspose;
};
// ...
UINT strides[2] = { sizeof(VertexPosNormalTex), sizeof(InstancedData) };
UINT offsets[2] = { 0, 0 };
ID3D11Buffer * buffers[2] = { vertexBuffer.Get(), instancedBuffer.Get() };
// 设置顶点/索引缓冲区
deviceContext->IASetVertexBuffers(0, 2, buffers, strides, offsets);
deviceContext->IASetInputLayout(instancePosNormalTexLayout.Get());
实例ID
系统值SV_InstanceID
可以告诉我们当前进行绘制的顶点来自哪个实例。通常在绘制N个实例的情况下,第一个实例的索引值为0,一直到最后一个实例索引值为N - 1.它可以应用在需要个性化的地方,比如使用一个纹理数组,然后不同的索引去映射到对应的纹理,以绘制出网格模型相同,但纹理不一致的物体。
按实例进行绘制
ID3D11DeviceContext::DrawIndexedInstanced方法--带索引数组的实例绘制
通常我们使用ID3D11DeviceContext::DrawIndexedInstanced
方法来绘制实例数据:
void ID3D11DeviceContext::DrawIndexedInstanced(
UINT IndexCountPerInstance, // [In]每个实例绘制要用到的索引数目
UINT InstanceCount, // [In]绘制的实例数目
UINT StartIndexLocation, // [In]起始索引偏移值
INT BaseVertexLocation, // [In]起始顶点偏移值
UINT StartInstanceLocation // [In]起始实例偏移值
);
下面是一个调用示例:
deviceContext->DrawIndexedInstanced(part.indexCount, numInsts, 0, 0, 0);
ID3D11DeviceContext::DrawInstanced方法--实例绘制
若没有索引数组,也可以用ID3D11DeviceContext::DrawInstanced
方法来进行绘制
void ID3D11DeviceContext::DrawInstanced(
UINT VertexCountPerInstance, // [In]每个实例绘制要用到的顶点数目
UINT InstanceCount, // [In]绘制的实例数目
UINT StartVertexLocation, // [In]起始顶点偏移值
UINT StartInstanceLocation // [In]起始实例偏移值
);
在调用实例化绘制后,输入装配器会根据所有顶点输入槽与实例输入槽进行笛卡尔积的排列组合,这里举个复杂的例子,有5个输入槽,其中顶点相关的输入槽含3个元素,实例相关的输入槽含2个元素:
输入槽索引 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
按顶点/实例 | 顶点 | 顶点 | 顶点 | 实例 | 实例 |
数据类型 | 顶点位置 | 顶点法向量 | 纹理坐标 | 世界矩阵 | 世界矩阵逆转置 |
索引0 | P0 | N0 | T0 | W0 | WInvT0 |
索引1 | P1 | N1 | T1 | W1 | WInvT1 |
索引2 | P2 | N2 | T2 | ------ | ---------- |
最终产生的实例数据流如下表,含3x2=6组结构体数据:
实例ID | 顶点ID | 顶点位置 | 顶点法向量 | 纹理坐标 | 世界矩阵 | 世界矩阵逆转置 |
---|---|---|---|---|---|---|
0 | 0 | P0 | N0 | T0 | W0 | WInv0 |
0 | 1 | P1 | N1 | T1 | W0 | WInv0 |
0 | 2 | P2 | N2 | T2 | W0 | WInv0 |
1 | 0 | P0 | N0 | T0 | W1 | WInv1 |
1 | 1 | P1 | N1 | T1 | W1 | WInv1 |
1 | 2 | P2 | N2 | T2 | W1 | WInv1 |
实例缓冲区的创建
和之前创建顶点/索引缓冲区的方式一样,我们需要创建一个ID3D11Buffer
,只不过在缓冲区描述中,我们需要将其指定为动态缓冲区(即D3D11_BIND_VERTEX_BUFFER
),并且要指定D3D11_CPU_ACCESS_WRITE
。
// 设置实例缓冲区描述
D3D11_BUFFER_DESC vbd;
ZeroMemory(&vbd, sizeof(vbd));
vbd.Usage = D3D11_USAGE_DYNAMIC;
vbd.ByteWidth = count * (UINT)sizeof(InstancedData);
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
// 新建实例缓冲区
HR(device->CreateBuffer(&vbd, nullptr, m_pInstancedBuffer.ReleaseAndGetAddressOf()));
因为我们不需要访问里面的数据,因此不用添加D3D11_CPU_ACCESS_READ
标记。
实例缓冲区数据的修改
若需要修改实例缓冲区的内容,则需要使用ID3D11DeviceContext::Map
方法将其映射到CPU内存当中。对于使用了D3D11_USAGE_DYNAMIC
标签的动态缓冲区来说,在更新的时候只能使用D3D11_MAP_WRITE_DISCARD
标签,而不能使用D3D11_MAP_WRITE
或者D3D11_MAP_READ_WRITE
标签。
将需要提交上去的实例数据存放到映射好的CPU内存区间后,使用ID3D11DeviceContext::Unmap
方法将实例数据更新到显存中以应用。
D3D11_MAPPED_SUBRESOURCE mappedData;
HR(deviceContext->Map(m_pInstancedBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));
InstancedData * iter = reinterpret_cast<InstancedData *>(mappedData.pData);
// 省略写入细节...
deviceContext->Unmap(m_pInstancedBuffer.Get(), 0);
视锥体裁剪
在前面的所有章节中,顶点的抛弃通常发生在光栅化阶段。这意味着如果一份模型数据的所有顶点在经过矩阵变换后都不会落在屏幕区域内的话,这些顶点数据将会经历顶点着色阶段,可能会经过曲面细分阶段和几何着色阶段,然后在光栅化阶段的时候才抛弃。让这些不会被绘制的顶点还要走过这么漫长的阶段才被抛弃,可以说是一种非常低效的行为。
视锥体裁剪,就是在将这些模型的相关数据提交给渲染管线之前,生成一个包围盒,与摄像机观察空间的视锥体进行碰撞检测。若为相交或者包含,则说明该模型对象是可见的,需要被绘制出来,反之则应当拒绝对该对象的绘制调用,或者不传入该实例对象相关的数据。这样做可以节省GPU资源以避免大量对不可见对象的绘制,对CPU的性能开销也不大。
可以说,若一个场景中的模型数目越多,或者视锥体的可视范围越小,那么视锥体裁剪的效益越大。
查看上图,可以知道的是物体A和D没有与视锥体发生碰撞,因此需要排除掉物体A的实例数据。而物体B和E与视锥体有相交,物体C则被视锥体所包含,这三个物体的实例数据都应当传递给实例缓冲区。
已知当前物体的包围盒、物体的世界变换矩阵、摄像机的观察矩阵和投影矩阵。其中投影矩阵本身可以构造出视锥碰撞体。我们可以使用世界变换矩阵将物体的包围盒变换到世界空间中,然后通过观察矩阵变换到观察空间中。这样我们就可以在观察空间中进行包围盒与视锥体的碰撞检测了:
BoundingBox boundingBox = object.GetModel()->boundingbox;
BoundingFrustum frustum;
BoundingFrustum::CreateFromMatrix(frustum, m_pCamera->GetProjMatrixXM());
XMMATRIX V = m_pCamera->GetViewMatrixXM();
BoundingOrientedBox localOrientedBox, orientedBox;
BoundingOrientedBox::CreateFromBoundingBox(localOrientedBox, boundingBox);
size_t sz = instancedData.size();
for (size_t i = 0; i < sz; ++i)
{
// 将有向包围盒从局部坐标系变换到视锥体所在的局部坐标系(观察坐标系)中
localOrientedBox.Transform(orientedBox, objectTransforms[i].GetLocalToWorldMatrixXM() * V);
// 相交检测
if (frustum.Intersects(orientedBox))
{
m_AcceptedIndices.push_back(i);
m_AcceptedData.push_back(instancedData[i]);
}
}
C++代码实现
GameApp::CreateRandomTrees方法--创建大量随机位置和方向的树
该方法创建了树的模型,并以随机的方式在一个大范围的圆形区域中生成了225棵树,即225个实例的数据(世界矩阵)。其中该圆形区域被划分成16个扇形区域,每个扇形划分成4个面,距离中心越远的扇面生成的树越多。
// 初始化树
Model* pModel = m_ModelManager.CreateFromFile("..\\Model\\tree.obj");
m_Trees.SetModel(pModel);
pModel->SetDebugObjectName("Trees");
XMMATRIX S = XMMatrixScaling(0.015f, 0.015f, 0.015f);
BoundingBox treeBox = m_Trees.GetModel()->boundingbox;
// 让树木底部紧贴地面位于y = -2的平面
treeBox.Transform(treeBox, S);
float Ty = -(treeBox.Center.y - treeBox.Extents.y + 2.0f);
// 随机生成256颗随机朝向的树
m_TreeInstancedData.resize(256);
m_TreeTransforms.resize(256);
std::mt19937 rng;
rng.seed(std::random_device()());
std::uniform_real<float> radiusNormDist(0.0f, 30.0f);
std::uniform_real<float> normDist;
float theta = 0.0f;
int pos = 0;
Transform transform;
transform.SetScale(0.015f, 0.015f, 0.015f);
for (int i = 0; i < 16; ++i)
{
// 取5-125的半径放置随机的树
for (int j = 0; j < 4; ++j)
{
// 距离越远,树木越多
for (int k = 0; k < 2 * j + 1; ++k, ++pos)
{
float radius = (float)(radiusNormDist(rng) + 30 * j + 5);
float randomRad = normDist(rng) * XM_2PI / 16;
transform.SetRotation(0.0f, normDist(rng) * XM_2PI, 0.0f);
transform.SetPosition(radius * cosf(theta + randomRad), Ty, radius * sinf(theta + randomRad));
m_TreeTransforms[pos] = transform;
XMMATRIX W = transform.GetLocalToWorldMatrixXM();
XMMATRIX WInvT = XMath::InverseTranspose(W);
XMStoreFloat4x4(&m_TreeInstancedData[pos].world, XMMatrixTranspose(W));
XMStoreFloat4x4(&m_TreeInstancedData[pos].worldInvTranspose, XMMatrixTranspose(WInvT));
}
}
theta += XM_2PI / 16;
}
创建实例缓冲区并上传数据
这里使用Buffer类创建动态顶点缓冲区,在经过视锥体裁剪后,将位于视锥体内的数据上传给该实例缓冲区。
注意:矩阵数据需要预先转置!
m_pInstancedBuffer = std::make_unique<Buffer>(m_pd3dDevice.Get(),
CD3D11_BUFFER_DESC(sizeof(BasicEffect::InstancedData) * 2048, D3D11_BIND_VERTEX_BUFFER,
D3D11_USAGE_DYNAMIC, D3D11_CPU_ACCESS_WRITE));
// 进行经过视锥体裁剪
// ...
// 上传实例数据
memcpy_s(m_pInstancedBuffer->MapDiscard(m_pd3dImmediateContext.Get()),
m_pInstancedBuffer->GetByteWidth(), instancedData.data(), instancedData.size() * sizeof(BasicEffect::InstancedData));
m_pInstancedBuffer->Unmap(m_pd3dImmediateContext.Get());
BasicEffect::DrawInstanced方法--绘制游戏对象的多个实例
该方法接受一个存有模型数据的对象、存有实例信息的顶点缓冲区,并需要指定实例的绘制数目:
void BasicEffect::DrawInstanced(ID3D11DeviceContext* deviceContext, Buffer& instancedBuffer, const GameObject& object, uint32_t numObjects)
{
deviceContext->IASetInputLayout(pImpl->m_pInstancePosNormalTexLayout.Get());
deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
auto pPass = pImpl->m_pEffectHelper->GetEffectPass("BasicInstance");
XMMATRIX V = XMLoadFloat4x4(&pImpl->m_View);
XMMATRIX P = XMLoadFloat4x4(&pImpl->m_Proj);
XMMATRIX VP = V * P;
VP = XMMatrixTranspose(VP);
pImpl->m_pEffectHelper->GetConstantBufferVariable("g_ViewProj")->SetFloatMatrix(4, 4, (FLOAT*)&VP);
const Model* pModel = object.GetModel();
size_t sz = pModel->meshdatas.size();
for (size_t i = 0; i < sz; ++i)
{
SetMaterial(pModel->materials[pModel->meshdatas[i].m_MaterialIndex]);
pPass->Apply(deviceContext);
MeshDataInput input = GetInputData(pModel->meshdatas[i]);
input.pVertexBuffers.back() = instancedBuffer.GetBuffer();
deviceContext->IASetVertexBuffers(0, (uint32_t)input.pVertexBuffers.size(),
input.pVertexBuffers.data(), input.strides.data(), input.offsets.data());
deviceContext->IASetIndexBuffer(input.pIndexBuffer, input.indexCount > 65535 ? DXGI_FORMAT_R32_UINT : DXGI_FORMAT_R16_UINT, 0);
deviceContext->DrawIndexedInstanced(input.indexCount, numObjects, 0, 0, 0);
}
}
剩余的代码都可以在GitHub项目中浏览。
效果展示
首先我们观察第1个场景。在这个场景下有256棵树,每棵树有大约6000个三角形。可以看到,开启视锥体裁剪后,可以有效减少GPU的绘制用时
然后是第2个场景。在这个场景下有2048个立方体,每个立方体12个三角形。可以看到,在绘制面数较少的重复模型时,实例化绘制可以带来显著优势。而开启视锥体裁剪的情况下,反而掉了帧数,说明CPU承担的压力会比较大。
当然,现在的游戏在追求60帧,甚至144帧流畅运行的情况下,0.5ms视锥体裁剪的运算量占一帧7ms的一小部分,但我们明显更希望在有限的时间让CPU干更多有意义的活。
注意:Debug模式下开启视锥体裁剪的效率可能比关闭还低,因为
std::vector
在debug模式下的性能相比release模式的确会有明显的损耗。
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。