1. 程式人生 > >DirectX11 With Windows SDK--20 硬體例項化與視錐體裁剪

DirectX11 With Windows SDK--20 硬體例項化與視錐體裁剪

前言

這一章將瞭解如何在DirectX 11利用硬體例項化技術高效地繪製重複的物體,以及使用視錐體裁剪技術提前將位於視錐體外的物體進行排除。

在此之前需要額外瞭解的章節如下:

硬體例項化(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棵樹的場景,使用者可以自行設定開啟/關閉視錐體裁剪或硬體例項化。若關閉硬體例項化,則是對每個物件單獨呼叫繪製命令。