Direct X 12 – Instancing And Frustum Culling 绘制实例与视锥体剔除

在本章节中,我们将学习instancing(绘制实例)与视锥体剔除。Instancing指的是在场景中多次绘制同一个物体。Instancing在性能上能够提供极大的提升,同时DX12对于instancing的支持也很详细。视锥体剔除指的是,通过一个简单的测试,不让位于视锥体之外的三角形被进一步处理。

目标:

1. 学会如何实现硬件端的instancing。

2. 熟悉并了解区域限制空间,并学会如何穿件与使用它们。

3. 学会如何实现视锥体剔除。

16.1 Hardware Instancing(硬件端的Instancing)

Instancing指的是在场景中多次绘制同一个物体,但是这些物体的位置,朝向,缩放大小,材质material,以及贴图可以各不相同。以下为一些例子:

1. 绘制一些不同的树木模型以构建一片森林。

2. 绘制一些小行星模型以构建一个小行星群。

3. 绘制一些不同的角色模型以构建人群。

如果每一个instance都需要复制顶点与索引数据,那无疑是很浪费资源的。相反,我们之保存一份基于物体本地坐标系的几何体信息(如,顶点列表与索引列表)。之后,我们多次绘制这个物体,但每一次都使用不同的世界矩阵与不同的材质material。

虽然上述操作节省了内存空间,但它仍然需要每一个物体的API开销。因为,对于每一个物体来说,我们需要设置其特有的材质material,它的世界矩阵,并且调用一次绘制指令(draw call)。即使DX12相比于DX11在执行draw call时已经减少了许多API的开销。DX12中关于instancing的API允许我们在一个draw call中绘制多次instance;此外,DX12支持的动态索引(Dynamic Indexing,上一章节的内容)让instancing技术的灵活性远高于DX11。

为什么我们需要担心API的开销呢?由于API的开销,一般来说CPU将限制DX11的应用(也就是说,CPU是游戏的瓶颈而不是GPU)。而原因可能是,LD(关卡设计师)在场景中放置了太多物体,而且它们的材质material与贴图各不相同,这也意味着每一个物体都需要一个单独的draw call。当对于每一个API调用都存在CPU开销时,为了保持实时渲染的速度,我们需要限制场景中draw call的数量。之后,渲染引擎将使用batching技术(将多个物体合并,使用单个draw call进行绘制),以减少draw call的数量。而硬件端的instancing就是通过API实现batching的一个方面。

16.1.1 函数DrawIndexedInstanced

在之前所有的demo中,我已经使用instanced数据来绘制场景中的物体了。然而,instance数量永远是1(函数的第二个参数):

cmdList->DrawIndexedInstanced(ri->IndexCount, 1,
    ri->StartIndexLocation, ri->BaseVertexLocation, 0);

第二个参数,InstanceCount,其声明了几何体的instance需要被绘制的数量。如果我们声明其为10,那么该几何体将被绘制10次。

但是,单单绘制一个物体10次并不能解决我们的问题。物体将被绘制在同一个地点,且带有相同的材质与贴图。所以,接下来我们需要知道如何声明每个instance的数据,这样就能够使用不同的位置信息,不同的材质material与不同的贴图绘制每一个instance。

16.1.2 Instance数据

在之前关于DX的书中,instance的数据将在输入装配阶段(IA stage)进行设置。当我们创建一个input layout时,可以将数据流分别设置为per-instance(基于每个instance,D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA)而不是per-vertex(基于每个顶点,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA)。之后,我们需要将第二个vertex buffer绑定至输入装配阶段,其包含了Instance数据。DX12仍然支持以这种方式将instance数据绑定至渲染管线,但是我们更倾向于使用一个更为“现代化”的方法。

现在我们需要创建一个StructuredBuffer,其包含了所有instance的数据。例如,我们需要绘制100个instance,那么这个StructuredBuffer就包含了100个Instance数据成员。之后我们将StructuredBuffer绑定至渲染管线,在顶点着色器中基于当前正在绘制的instance来读取StructuredBuffer中的成员。那么我们如何知道在顶点着色器中正在绘制的是哪一个instance呢?DX12提供了系统值SV_InstanceID,而我们能在顶点着色器中使用该系统值来鉴别instance。例如,第一个instance的所有顶点,它们的SV_InstanceID都为0;第二个instance的所有顶点,它们的SV_InstanceID都为1,以此类推。所以,在顶点着色器中,我们可以将该系统值作为索引,读取StructuredBuffer中的成员。代码实例如下:

// HLSL

// 默认光源数量
#ifndef NUM_DIR_LIGHTS
    #define NUM_DIR_LIGHTS 3
#endif

#ifndef NUM_POINT_LIGHTS
    #define NUM_POINT_LIGHTS 0
#endif

#ifndef NUM_SPOT_LIGHTS
    #define NUM_SPOT_LIGHTS 0
#endif

// 光照计算
#include "LightingUtil.hlsl"

struct InstanceData
{
    float4x4 World;
    float4x4 TexTransform;
    uint MaterialIndex;
    uint InstPad0;
    uint InstPad1;
    uint InstPad2;
};

struct MaterialData
{
    float4 DiffuseAlbedo;
    float3 FresnelR0;
    float Roughness;
    float4x4 MatTransform;
    uint DiffuseMapIndex;
    uint MapPad0;
    uint MapPad1;
    uint MapPad2;
};

// 贴图数组,shader model 5.1之后的版本才支持该特性。
// 不同于Texture2DArray,贴图数组中的贴图成员的尺寸与格式可以各不相同,
// 其灵活性高于Texture2DArray。
Texture2D gDiffuseMap[7] : register(t0);

// 将其存储于space1,这样就不会与gDiffuseMap发生资源重叠。
StructuredBuffer<InstanceData> gInstanceData : register(t0, space1);
StructuredBuffer<MaterialData> gMaterialData : register(t1, space1);

SamplerState gsamPointWrap : register(s0);
SamplerState gsamPointClamp : register(s1);
SamplerState gsamLinearWrap : register(s2);
SamplerState gsamLinearClamp : register(s3);
SamplerState gsamAnisotropicWrap : register(s4);
SamplerState gsamAnisotropicClamp : register(s5);

// 基于渲染通道的constant buffer
cbuffer cbPass : register(b0)
{
    float4x4 gView;
    float4x4 gInvView;
    float4x4 gProj;
    float4x4 gInvProj;
    float4x4 gViewProj;
    float4x4 gInvViewProj;
    float3 gEyePosW;
    float cbPerObjectPad1;
    float2 gRenderTargetSize;
    float2 gInvRenderTargetSize;
    float gNearZ;
    float gFarZ;
    float gTotalTime;
    float gDeltaTime;
    float4 gAmbientLight;

    Light gLights[MaxLights];
};

struct VertexIn
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float2 TexC : TEXCOORD;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION;
    float3 NormalW : NORMAL;
    float2 TexC : TEXCOORD;

    // nointerpolation意味着该变量不会基于三角形进行插值
    nointerpolation uint MatIndex : MATINDEX;
};

VertexOut VS(VertexIn vin, uint instanceID : SV_InstanceID)
{
    VertexOut vout = (VertexOut)0.f;

    // 读取instance数据
    InstanceData instData = gInstanceData[instanceID];
    float4x4 world = instData.World;
    float4x4 texTransform = instData.TexTransform;
    uint matIndex = instData.MaterialIndex;
    vout.MatIndex = matIndex;

    // 读取材质material数据
    MaterialData matData = gMaterialData[matIndex];

    // 转换至世界坐标系空间
    float4 posW = mul(float4(vin.PosL, 1.f), world);
    vout.PosW = posW.xyz;

    // 假设没有进行不对称缩放,
    // 否则我们需要使用世界矩阵的逆转置矩阵。
    vout.NormalW = mul(vin.NormalL, (float3x3)world);

    // 转换至齐次剪裁空间
    vout.PosH = mul(posW, gViewProj);

    // 输出顶点属性用于三角形上的插值。
    float4 texC = mul(float4(vin.TexC, 0.f, 1.f), texTransform);
    vout.TexC = mul(texC, matData.MatTransform).xy;

    return vout;
}

float4 PS(VertexOut pin) : SV_Target
{
    // 读取材质material数据
    MaterialData matData = gMaterialData[pin.MatIndex];
    float4 diffuseAlbedo = matData.DiffuseAlbedo;
    float3 fresnelR0 = matData.FresnelR0;
    float roughness = matData.Roughness;
    uint diffuseTexIndex = matData.DiffuseMapIndex;

    // 通过索引读取贴图数组中的成员
    diffuseAlbedo *= gDiffuseMap[diffuseTexIndex].Sample(gsamLinearWrap, pin.TexC);

    // 插值后的法线向量可能不是单位向量
    pin.NormalW = normalize(pin.NormalW);

    // 从像素点指向camera的向量
    float3 toEyeW = normalize(gEyePosW - pin.PosW);

    // 光照计算
    float4 ambient = gAmbientLight * diffuseAlbedo;
    Material mat = { diffuseAlbedo, fresnelR0, roughness };
    float4 directLight = ComputeDirectLighting(gLights, mat,
        pin.PosW, pin.NormalW, toEyeW);
    float4 litColor = ambient + directLight;

    // 读取α通道的值
    litColor.a = diffuseAlbedo.a;

    return litColor;
}

我们可以看到,在HLSL文件中已经没有了每个物体(cbPerObject)的constant buffer。每个物体材质以及位置信息则来自于InstanceData。同时我们也使用了索引来读取材质material与贴图。这样,在一个draw call中我们就能让instance各不相同!以下是基于上述着色器程序的root signature代码:

CD3DX12_DESCRIPTOR_RANGE texTable;
texTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 7, 0, 0);

// root parameter可以是root table,root descriptor或者root constants
CD3DX12_ROOT_PARAMETER slotRootParameter[4];

slotRootParameter[0].InitAsShaderResourceView(0, 1);
slotRootParameter[1].InitAsShaderResourceView(1, 1);
slotRootParameter[2].InitAsConstantBufferView(0);
slotRootParameter[3].InitAsDescriptorTable(1, &texTable, D3D12_SHADER_VISIBILITY_PIXEL);

auto staticSamplers = GetStaticSamplers();

// root signature由root parameter组成
CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(4, slotRootParameter,
    (UINT)staticSamplers.size(), staticSamplers.data(),
    D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);

正如上一章节所示,我们只在每一帧开始时绑定场景中所有被使用的材质material与贴图。而唯一需要基于每一次draw call设置的资源就是每一个Instance的StructuredBuffer:

void InstancingAndCullingApp::DrawRenderItems(
    ID3D12GraphicsCommandList* cmdList,
    const std::vector<RenderItem*>& ritems)
{
    // 对于每一个render item
    for(size_t i = 0; i < ritems.size(); ++i) 
    { 
        auto ri = ritems[i]; 
        cmdList->IASetVertexBuffers(0, 1, &ri->Geo->VertexBufferView());
        cmdList->IASetIndexBuffers(&ri->Geo->IndexBufferView());
        cmdList->IASetPrimitiveTopology(ri->PrimitiveType);

        // 为当前render item设置instance buffer。
        // 对于StructuredBuffer,我们可以将其设置为root descriptor,
        // 这样便无需使用descriptor heap了。
        auto instanceBuffer = mCurrFrameResource->InstanceBuffer->Resource();
        cmdList->SetGraphicsRootShaderResourceView(
            0, instanceBuffer->GetGPUVirtualAddress());

        cmdList->DrawIndexedInstanced(ri->IndexCount, 
            ri->InstanceCount, ri->StartIndexLocation,
            ri->BaseVertexLocation, 0);
    }
}

16.1.3 创建Instance Buffer

instance buffer存储了每一个instance的数据,其包含的数据类似于之前我们所使用的每一个物体的constant buffer。在CPU端,instance buffer的数据结构如下:

struct InstanceData
{
    DirectX::XMFLOAT4X4 World = MathHelper::Identity4x4();
    DirectX::XMFLOAT4X4 TexTransform = MathHelper::Identity4x4();
    UINT MaterialIndex;
    UINT InstancePad0;
    UINT InstancePad1;
    UINT InstancePad2;
};

由于render item的结构体中存储了InstanceCount,也就是instance的数量,所以我们将每一个instance的数据存储于RenderItem结构体中:

struct RenderItem
{
    ...

    std::vector<InstanceData> Instances;

    ...
};

为了让GPU使用instance数据,我们需要创建一个StructuredBuffer,其数据类型为InstanceData。此外,该buffer需要是一个动态buffer(例如,upload buffer),这样每一帧我们都能对该buffer进行更新;在demo中,我们只会将可见的的instance的数据拷贝至StructuredBuffer(视锥体剔除,我们将在16.3小节学习),同时可见的instance将随着camera的位移而改变。我们仍将使用UploadBuffer类来创建一个动态buffer:

struct FrameResource
{
public:
    FrameResource(ID3D12Device* device, UINT passCount,
        UINT maxInstanceCount, UINT materialCount);
    FrameResource(const FrameResource& rhs) = delete;
    FrameResource& operator=(const FrameResource& rhs) = delete;
    ~FrameResource();

    // 直到GPU处理完所有指令之后,我们才能重置command allocator。
    // 所以每一个FrameResource需要一个独立的command allocator。
    Microsoft::WRL::ComPtr<ID3D12CommandAllocator> CmdListAlloc;

    // 直到GPU处理完所有关于某个constant buffer的指令,
    // 我们才能更新该constant buffer。
    // 所以每一个FrameResource需要独立的constant buffer。
    std::unique_ptr<UploadBuffer<FrameConstant>> FrameCB = nullptr;
    std::unique_ptr<UploadBuffer<PassConstants>> PassCB = nullptr;
    std::unique_ptr<UploadBuffer<MaterialData>> MaterialBuffer = nullptr;

    // 请注意:在demo中,我们只有一个render-item拥有instance,
    // 所以我们只需要一个StructuredBuffer来存储instance数据。
    // 为了让demo能够支持多个render item的instance,
    // 每一个render item都需要一个StructuredBuffer,
    // 同时我们需要为每一个buffer分配足够的空间以存储所有instance数据。
    // 这看上去需要很多空间,但实际上并不会多余我们之前使用的每个物体的constant buffer。
    // 举例来说,我们之前需要创建一个constant buffer存储1000个物体的数据。
    // 使用instance技术之后,我们只不过创建一个StructuredBuffer,其能够存储1000个物体的数据
    std::unique_ptr<UploadBuffer<InstanceData>> InstanceBuffer = nullptr;

    UINT64 Fence = 0;
};

FrameResource::FrameResource(ID3D12Device* device,
    UINT passCount, UINT maxInstanceCount, UINT materialCount)
{
    ThrowIfFailed(device->CreateCommandAllocator(
        D3D12_COMMAND_LIST_TYPE_DIRECT,
        IID_PPV_ARGS(CmdListAlloc.GetAddressOf())));
    
    PassCB = std::make_unique<UploadBuffer<PassConstants>>(
        device, passCount, true);
    
    MaterialCB = std::make_unique<UploadBuffer<MaterialData>>(
        device, materialCount, false);

    InstanceBuffer = std::make_unique<UploadBuffer<InstanceData>>(
        device, maxInstanceCount, false);
}

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据