前言
這一章將瞭解如何在DirectX 11利用硬件實例化技術高效地繪製重複的物體,以及使用視錐體裁剪技術提前將位於視錐體外的物體進行排除。
在此之前需要額外瞭解的章節如下:
章節回顧 |
---|
18 使用DirectXCollision庫進行碰撞檢測 |
19 模型加載:obj格式的讀取及使用二進制文件提升讀取效率 |
DirectX11 With Windows SDK完整目錄
硬件實例化(Hardware Instancing)
硬件實例化指的是在場景中繪製同一個物體多次,但是是以不同的位置、旋轉、縮放、材質以及紋理來繪製(比如一棵樹可能會被多次使用以構建出一片森林)。在以前,每次實例繪製(Draw方法)都會引發一次頂點緩衝區和索引緩衝區經過輸入裝配階段傳遞進渲染管線中,大量重複的繪製則意味着多次反覆的輸入裝配操作,會引發十分龐大的性能開銷。事實上在繪製同樣物體的時候頂點緩衝區和索引緩衝區應當只需要傳遞一次,然後真正需要多次傳遞的也應該是像世界矩陣、材質、紋理等這些可能會經常變化的數據。
要能夠實現上面的這種操作,還需要圖形庫底層API本身能夠支持按對象繪製。對於每個對象,我們必須設置它們各自的材質、世界矩陣等,然後纔是調用繪製命令。儘管在Direct3D 10和後續的版本已經將原本Direct3D 9的一些API重新設計以儘可能最小化性能上的開銷,部分多餘的開銷仍然存在。因此,Direct3D提供了一種機制,不需要通過API上的額外性能開銷來實現實例化,我們稱之爲硬件實例化。
爲什麼要擔憂API性能開銷呢?Direct3D 9應用程序通常因爲API導致在CPU上遇到瓶頸,而不是在GPU。以前關卡設計師喜歡使用單一材質和紋理來繪製許多對象,因爲對於它們來說需要經常去單獨改變它的狀態並且去調用繪製。場景將會被限制在幾千次的調用繪製以維持實時渲染的速度,主要在於這裏的每次API調用都會引起高級別的CPU性能開銷。現在圖形引擎可以使用批處理技術以最小化繪製調用的次數。硬件實例化是API幫助執行批處理的一個方面。
頂點着色器
硬件實例化需要在輸入裝配階段額外提供以二進制數據流表示的實例數據才能工作,而不僅僅是提供頂點/索引數據。然後我們將通過調用對應的Draw命令來告訴硬件需要繪製這個網格模型多少次,即繪製多少個這樣的實例。對應頂點着色器來說,可以同時接受來自頂點信息和實例信息的數據作爲輸入:
struct InstancePosNormalTex
{
float3 PosL : POSITION;
float3 NormalL : NORMAL;
float2 Tex : TEXCOORD;
row_major matrix World : World;
row_major matrix WorldInvTranspose : WorldInvTranspose;
};
其中前面三項數據來自頂點,後面兩項數據則是來自一個實例,因爲對於一個實例來說,在繪製的時候它的世界矩陣是不會發生變化的。
輸出的結構體和以前一樣:
struct VertexPosHWNormalTex
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION; // 在世界中的位置
float3 NormalW : NORMAL; // 法向量在世界中的方向
float2 Tex : TEXCOORD;
};
頂點着色器代碼變化如下:
VertexPosHWNormalTex VS(InstancePosNormalTex pIn)
{
VertexPosHWNormalTex pOut;
row_major matrix viewProj = mul(gView, gProj);
pOut.PosW = mul(float4(pIn.PosL, 1.0f), pIn.World).xyz;
pOut.PosH = mul(float4(pOut.PosW, 1.0f), viewProj);
pOut.NormalW = mul(pIn.NormalL, (float3x3) pIn.WorldInvTranspose);
pOut.Tex = pIn.Tex;
return pOut;
}
至於像素着色器,和上一章爲模型所使用的着色器的保持一致。
實例ID
系統值SV_InstanceID
可以告訴我們當前進行繪製的頂點來自哪個實例。通常在繪製N個實例的情況下,第一個實例的索引值爲0,一直到最後一個實例索引值爲N - 1.它可以應用在需要個性化的地方,比如使用一個紋理數組,然後不同的索引去映射到對應的紋理,以繪製出網格模型相同,但紋理不一致的物體。
流式實例化數據
和之前頂點着色器的做法一樣,我們需要使用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.
對於前面的結構體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 VertexPosNormalColor
{
DirectX::XMFLOAT3 pos;
DirectX::XMFLOAT3 normal;
DirectX::XMFLOAT4 color;
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(), mInstancedBuffer.Get() };
// 設置頂點/索引緩衝區
deviceContext->IASetVertexBuffers(0, 2, buffers, strides, offsets);
deviceContext->IASetInputLayout(instancePosNormalTexLayout.Get());
繪製實例數據
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]起始實例偏移值
);
實例緩衝區的創建
和之前創建頂點/索引緩衝區的方式一樣,我們需要創建一個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(XMMATRIX) * 2;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
// 新建實例緩衝區
HR(device->CreateBuffer(&vbd, nullptr, mInstancedBuffer.ReleaseAndGetAddressOf()));
要注意這裏ByteWidth
每個實例使用兩個矩陣,一個世界矩陣,一個是世界矩陣求逆後的轉置。
因爲我們不需要訪問裏面的數據,因此不用添加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(mInstancedBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));
InstancedData * iter = reinterpret_cast<InstancedData *>(mappedData.pData);
// 省略寫入細節...
deviceContext->Unmap(mInstancedBuffer.Get(), 0);
視錐體裁剪
在前面的所有章節中,頂點的拋棄通常發生在光柵化階段。這意味着如果一份模型數據的所有頂點在經過矩陣變換後都不會落在屏幕區域內的話,這些頂點數據將會經歷頂點着色階段,可能會經過曲面細分階段和幾何着色階段,然後在光柵化階段的時候才拋棄。讓這些不會被繪製的頂點還要走過這麼漫長的階段才被拋棄,可以說是一種非常低效的行爲。
視錐體裁剪,就是在將這些模型的相關數據提交給渲染管線之前,生成一個包圍盒,與攝像機觀察空間的視錐體進行碰撞檢測。若爲相交或者包含,則說明該模型對象是可見的,需要被繪製出來,反之則應當拒絕對該對象的繪製調用,或者不傳入該實例對象相關的數據。這樣做可以節省GPU資源以避免大量對不可見對象的繪製,對CPU的性能開銷也不大。
可以說,若一個場景中的模型數目越多,或者視錐體的可視範圍越小,那麼視錐體裁剪的效益越大。
查看上圖,可以知道的是物體A和D沒有與視錐體發生碰撞,因此需要排除掉物體A的實例數據。而物體B和E與視錐體有相交,物體C則被視錐體所包含,這三個物體的實例數據都應當傳遞給實例緩衝區。
視錐體裁剪有三種等價的代碼表現形式。需要已知當前物體的包圍盒、世界變換矩陣、觀察矩陣和投影矩陣。其中投影矩陣本身可以構造出視錐體包圍盒。
下面有關視錐體裁剪的方法都放進了Collision.h
中。
方法1
現在已知物體的包圍盒位於自身的局部座標系,我們可以使用世界變換矩陣將其變換到世界空間中。同樣,由投影矩陣構造出來的視錐體包圍盒也位於自身局部座標系中,而觀察矩陣實質上是從世界矩陣變換到視錐體所處的局部座標系中。因此,我們可以使用觀察矩陣的逆矩陣,將視錐體包圍盒也變換到世界空間中。這樣就好似物體與視錐體都位於世界空間中,可以進行碰撞檢測了:
std::vector<XMMATRIX> XM_CALLCONV Collision::FrustumCulling(
const std::vector<XMMATRIX>& Matrices,const BoundingBox& localBox, FXMMATRIX View, CXMMATRIX Proj)
{
std::vector<DirectX::XMMATRIX> acceptedData;
BoundingFrustum frustum;
BoundingFrustum::CreateFromMatrix(frustum, Proj);
XMMATRIX InvView = XMMatrixInverse(nullptr, View);
// 將視錐體從局部座標系變換到世界座標系中
frustum.Transform(frustum, InvView);
BoundingOrientedBox localOrientedBox, orientedBox;
BoundingOrientedBox::CreateFromBoundingBox(localOrientedBox, localBox);
for (auto& mat : Matrices)
{
// 將有向包圍盒從局部座標系變換到世界座標系中
localOrientedBox.Transform(orientedBox, mat);
// 相交檢測
if (frustum.Intersects(orientedBox))
acceptedData.push_back(mat);
}
return acceptedData;
}
方法2
該方法對應的正是龍書中所使用的裁剪方法,基本思路爲:分別對觀察矩陣和世界變換矩陣求逆,然後使用觀察逆矩陣將視錐體從自身座標系搬移到世界座標系,再使用世界變換的逆矩陣將其從世界座標系搬移到物體自身座標系來與物體進行碰撞檢測。改良龍書的碰撞檢測代碼如下:
std::vector<DirectX::XMMATRIX> XM_CALLCONV Collision::FrustumCulling2(
const std::vector<DirectX::XMMATRIX>& Matrices,const DirectX::BoundingBox& localBox, DirectX::FXMMATRIX View, DirectX::CXMMATRIX Proj)
{
std::vector<DirectX::XMMATRIX> acceptedData;
BoundingFrustum frustum, localFrustum;
BoundingFrustum::CreateFromMatrix(frustum, Proj);
XMMATRIX InvView = XMMatrixInverse(nullptr, View);
for (auto& mat : Matrices)
{
XMMATRIX InvWorld = XMMatrixInverse(nullptr, mat);
// 將視錐體從觀察座標系(或局部座標系)變換到物體所在的局部座標系中
frustum.Transform(localFrustum, InvView * InvWorld);
// 相交檢測
if (localFrustum.Intersects(localBox))
acceptedData.push_back(mat);
}
return acceptedData;
}
方法3
這個方法理解起來也比較簡單,直接將物體先用世界變換矩陣從物體自身座標系搬移到世界座標系,然後用觀察矩陣將其搬移到視錐體自身的局部座標系來與視錐體進行碰撞檢測。代碼如下:
std::vector<DirectX::XMMATRIX> XM_CALLCONV Collision::FrustumCulling3(
const std::vector<DirectX::XMMATRIX>& Matrices,const DirectX::BoundingBox& localBox, DirectX::FXMMATRIX View, DirectX::CXMMATRIX Proj)
{
std::vector<DirectX::XMMATRIX> acceptedData;
BoundingFrustum frustum;
BoundingFrustum::CreateFromMatrix(frustum, Proj);
BoundingOrientedBox localOrientedBox, orientedBox;
BoundingOrientedBox::CreateFromBoundingBox(localOrientedBox, localBox);
for (auto& mat : Matrices)
{
// 將有向包圍盒從局部座標系變換到視錐體所在的局部座標系(觀察座標系)中
localOrientedBox.Transform(orientedBox, mat * View);
// 相交檢測
if (frustum.Intersects(orientedBox))
acceptedData.push_back(mat);
}
return acceptedData;
}
這三種方法的裁剪表現效果是一致的。
C++代碼實現
GameApp::CreateRandomTrees方法--創建大量隨機位置和方向的樹
該方法創建了樹的模型,並以隨機的方式在一個大範圍的圓形區域中生成了225棵樹,即225個實例的數據(世界矩陣)。其中該圓形區域被劃分成16個扇形區域,每個扇形劃分成4個面,距離中心越遠的扇面生成的樹越多。
void GameApp::CreateRandomTrees()
{
// 初始化樹
mObjReader.Read(L"Model\\tree.mbo", L"Model\\tree.obj");
mTrees.SetModel(Model(md3dDevice, mObjReader));
XMMATRIX S = XMMatrixScaling(0.015f, 0.015f, 0.015f);
BoundingBox treeBox = mTrees.GetLocalBoundingBox();
// 獲取樹包圍盒頂點
mTreeBoxData = Collision::CreateBoundingBox(treeBox, XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f));
// 讓樹木底部緊貼地面位於y = -2的平面
treeBox.Transform(treeBox, S);
XMMATRIX T0 = XMMatrixTranslation(0.0f, -(treeBox.Center.y - treeBox.Extents.y + 2.0f), 0.0f);
// 隨機生成256顆隨機朝向的樹
float theta = 0.0f;
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)
{
float radius = (float)(rand() % 30 + 30 * j + 5);
float randomRad = rand() % 256 / 256.0f * XM_2PI / 16;
XMMATRIX T1 = XMMatrixTranslation(radius * cosf(theta + randomRad), 0.0f, radius * sinf(theta + randomRad));
XMMATRIX R = XMMatrixRotationY(rand() % 256 / 256.0f * XM_2PI);
XMMATRIX World = S * R * T0 * T1;
mInstancedData.push_back(World);
}
}
theta += XM_2PI / 16;
}
}
GameObject::ResizeBuffer方法--重新調整實例緩衝區的大小
若實例緩衝區的大小容不下當前增長的實例數據,則需要銷燬原來的實例緩衝區,並重新創建一個更大的,以確保剛好能容得下之前的大量實例數據。
void GameObject::ResizeBuffer(ComPtr<ID3D11Device> device, size_t count)
{
// 設置實例緩衝區描述
D3D11_BUFFER_DESC vbd;
ZeroMemory(&vbd, sizeof(vbd));
vbd.Usage = D3D11_USAGE_DYNAMIC;
vbd.ByteWidth = count * (UINT)sizeof(XMMATRIX) * 2;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
// 創建實例緩衝區
HR(device->CreateBuffer(&vbd, nullptr, mInstancedBuffer.ReleaseAndGetAddressOf()));
}
GameObject::DrawInstanced方法--繪製遊戲對象的多個實例
該方法接受一個裝滿世界矩陣的數組,把數據裝填進實例緩衝區(若容量不夠則重新擴容),然後交給設備上下文進行實例的繪製
void GameObject::DrawInstanced(ComPtr<ID3D11DeviceContext> deviceContext, BasicFX & effect, const std::vector<DirectX::XMMATRIX>& data)
{
std::vector<XMMATRIX> acceptedData;
D3D11_MAPPED_SUBRESOURCE mappedData;
UINT numInsts = (UINT)data.size();
// 若傳入的數據比實例緩衝區還大,需要重新分配
if (numInsts > mCapacity)
{
ComPtr<ID3D11Device> device;
deviceContext->GetDevice(device.GetAddressOf());
ResizeBuffer(device, numInsts);
}
HR(deviceContext->Map(mInstancedBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));
InstancedData * iter = reinterpret_cast<InstancedData *>(mappedData.pData);
XMMATRIX worldInvTranspose;
for (auto& mat : data)
{
worldInvTranspose = XMMatrixTranspose(XMMatrixInverse(nullptr, mat));
iter->world = mat;
iter->worldInvTranspose = worldInvTranspose;
iter++;
}
deviceContext->Unmap(mInstancedBuffer.Get(), 0);
UINT strides[2] = { sizeof(VertexPosNormalTex), sizeof(InstancedData) };
UINT offsets[2] = { 0, 0 };
ID3D11Buffer * buffers[2] = { nullptr, mInstancedBuffer.Get() };
for (auto& part : mModel.modelParts)
{
buffers[0] = part.vertexBuffer.Get();
// 設置頂點/索引緩衝區
deviceContext->IASetVertexBuffers(0, 2, buffers, strides, offsets);
deviceContext->IASetIndexBuffer(part.indexBuffer.Get(), part.indexFormat, 0);
// 更新數據並應用
effect.SetTextureAmbient(part.texA);
effect.SetTextureDiffuse(part.texD);
effect.SetMaterial(part.material);
effect.Apply(deviceContext);
deviceContext->DrawIndexedInstanced(part.indexCount, numInsts, 0, 0, 0);
}
}
剩餘的代碼都可以在GitHub項目中瀏覽。
效果展示
該項目展示了一個同時存在225棵樹的場景,用戶可以自行設置開啓/關閉視錐體裁剪或硬件實例化。若關閉硬件實例化,則是對每個對象單獨調用繪製命令。