DirectX12 3D 游戏开发与实战第七章内容(上)
利用Direct3D绘制几何体(续)
学习目标
- 学会一种无须每帧都要刷新命令队列的渲染流程,以此来优化性能
- 了解另外两种根签名参数类型:根常量和根描述符
- 探索如何在程序中生成和绘制常见的几何体:如栅格、圆台和球体
- 研究怎样通过动态顶点缓冲区来更新CPU中的顶点数据,并且向GPU上传顶点的新的位置信息
7.1 帧资源
首先先回顾一下CPU和GPU并行工作的情形,CPU构建并提交命令列表,同时还需要执行一些必要的工作,而GPU则负责处理命令队列中的各种命令。我们的目标则是使CPU和GPU持续工作,从而充分利用系统当中的可用硬件资源。
在上一章的演示程序中,我们在绘制每一帧时都使CPU和GPU进行一次同步,这样做的主要原因有两个:
1、在GPU未执行完命令列表分配器中的所有命令之前,我们都不能将命令列表分配器重置。如果不对CPU和GPU进行同步,那么GPU中可能会有一些未执行的命令被清除
2、在GPU未完成与常量缓冲区相关的绘制命令之前,不能让CPU更新这些常量缓冲区,如果不对CPU和GPU进行同步,那么在GPU在绘制第n帧画面时,常量缓冲区存储的可能是绘制第n + 1帧画面所需的数据
所以,在上一章的演示程序中,我们在每一帧绘制的结尾都会调用D3DApp::FlushCommandQueue函数,以确保GPU中的命令可以被正确执行,这种办法虽然有效,但效率却很低:
1、在每一帧的起始阶段,GPU不会执行任何命令,因为CPU还没有向GPU提交命令
2、在每一帧的收尾阶段,CPU会等待GPU完成命令的处理
所以,GPU和CPU在每一帧都存在一些空闲的时间被浪费。解决此问题的一种方法是:以CPU每一帧都需要更新的资源作为基本元素,创建一个环形数组(circular array),我们称这些资源为帧资源(frame resource),而这种循环数组一般都是由3个帧资源元素构成的。该方法的思路是在处理第n帧的时候,CPU将从环形数组(帧资源数组)中获取下一个可用的帧资源(即没有正在被GPU使用的帧资源),趁着GPU在处理第n-1帧的画面时,CPU将为第n帧的绘制准备资源。下面我们将创建一个仅含有常量缓冲区的帧资源类
struct FrameResource
{
public:
FrameResource(ID3D12Device * device, UINT passCount, UINT objectCount);
FrameResource(const FrameResource& rhs) = delete;
FrameResource& operator=(const FrameResource& rhs) = delete;
~FrameResource();
//GPU处理完与命令分配器相关的命令之前,不能对命令分配器进行重置操作
//所以要每一帧都要有属于它自己的命令分配器
ComPtr<ID3D12CommandAllocator> CmdListAlloc;
//在GPU执行与常量缓冲区相关的命令之前,不能对常量缓冲区进行重置
//所以每一帧都要有属于它自己的常量缓冲区
std::unique_ptr<UploadBuffer<PassConstants>> passCB = nullptr;
std::unique_ptr<UploadBuffer<ObjectConstants>> ObjectCB = nullptr;
//通过围栏值将命令标记到此围栏点,这可以使我们检测GPU是否还在使用这些帧资源
UINT64 Fence = 0;
};
FrameResource::FrameResource(ID3D12Device * device, UINT passCount, UINT objectCount)
{
//创建命令分配器
ThrowIfFailed(device->CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE_DIRECT,
IID_PPV_ARGS(CmdListAlloc.GetAddressOf())
));
passCB = std::make_unique<UploadBuffer<PassConstants>>(device, passCount,true);
ObjectCB = std::make_unique<UploadBuffer<ObjectConstants>>(device, objectCount, true);
}
FrameResource::~FrameResource()
{
}
7.2 渲染项
绘制一个物体需要设置多种参数,例如绑定顶点缓冲区和索引缓冲区、绑定和物体有关的常量数据、设定图元类型以及指定DrawIndexedInstanced方法的参数。随着场景中物体数量的不断增加,我们需要创建一个轻量级结构体来存储绘制物体所需要的数据。我们把单次绘制调用过程中需要向渲染流水线提交的数据集称为渲染项。(由于每一个物体的特征不同,绘制过程中需要的参数也不一样,因此该结构体中的数据也会因具体程序而异)。
下面我们将展示本章演示程序的渲染项结构体(RenderItem):
struct RenderItem
{
RenderItem() = default;
//描述物体局部空间相对于世界空间的世界矩阵
//该世界矩阵定义了物体位于世界空间的位置、朝向以及大小
XMFLOAT4X4 World = MathHelper::Identity4x4();
//用于更新标志(dirty flag)来表示物体的相关数据已经发生改变,这意味着
//我们需要更新常量缓冲区,由于每个FrameResource都有一个物体的常量缓冲
//区,所以我们需要对每一个FrameResource进行更新。所以我们要将NumFramesDirty
//的值设为gNumFrameResource,从而使每一个帧资源都得到更新
int NumFramesDirty = gNumFrameResources;
//指向当前GPU常量缓冲区对应的物体常量缓冲区
UINT ObjectIndex = -1;
//此渲染项参与绘制的几何体(绘制一个几何体可能需要多个渲染项)
MeshGeometry* Geo = nullptr;
//图元拓扑
D3D12_PRIMITIVE_TOPOLOGY PrimitiveType = D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
//DrawIndexedInstanced方法的参数
UINT IndexCount = 0;
UINT StartIndexLoaction = 0;
int BaseVertexLocation = 0;
};
我们的应用程序会根据各渲染项的绘制目的,根据不同PSO(流水线状态对象)所需要的渲染项,将它们划分到不同的向量中
//存有所有渲染项的向量
std::vector<std::unique_ptr<RenderItem>> mAllRitems;
//根据PSO来划分渲染项
std::vector<RenderItem*> mOpaqueRitems;
std::vector<RednerItem*> mTransparentRitems;
7.3 渲染过程中所用到的常量数据
从7.1节可以看出,我们在自己实现的FrameResource类中加入了新的常量缓冲区,
std::unique_ptr<UploadBuffer<PassConstant>> PassCB = nullptr;
随着演示代码复杂度的不断提升,该缓冲区中存储的内容会会根据特定的渲染过程(render pass)而确定下来,它们是着色器程序中要访问的极有用的数据,虽然外面不会用到PassCB中的全部数据,但留着它们并不是一件坏事,因为迟早会用到:
struct ConstantBufferPass
{
float4x4 gView; //观察矩阵
float4x4 gInvView; //观察矩阵的逆矩阵
float4x4 gProj; //投影矩阵
float4x4 gInvProj; //投影矩阵的逆矩阵
float3 gEyePosW; //观察点
float cbPerObjectPad1;
float2 gRenderTargetSize;
float2 gInvRenderTargetSize;
float gNearZ; //观察点到近平面的距离
float gFarZ; //观察点到远平面的距离
float gTotalTime; //游戏总时间
float gDeltaTime; //上一帧与本帧的时间间隔
};
ConstantBuffer<ConstantBufferPass> cbPass : register(b1);
此时外面也修改了物体常量缓冲区,使它只存储世界矩阵
struct ObjectConstant
{
float4x4 gWorld;
};
ConstantBuffer<ObjectConstant> cbPerObject : register(b0);
我们做出上述调整的主要原因是:基于资源的更新频率对常量数据进行分组。在每次渲染过程(render pass)中,我们只需要把本次所用的常量(cbPass)更新一次即可,在物体的世界矩阵发生改变时,更新物体的cbPerObject即可。
在本章的演示程序中,将通过下列方法来更新渲染过程常量缓冲区和物体常量缓冲区。在绘制每一帧的画面时,这两个方法都会被调用一次:
物体常量缓冲区的更新
void ShapesApp::UpdateObjectCBs(const GameTimer & gt)
{
//获取当前帧资源中的常量缓冲区
auto currObjectCB = mCurrFrameResource->ObjectCB.get();
//遍历所有渲染项
for (auto& e : mAllRitems)
{
//如果常量发生了改变就更新所有帧资源的常量缓冲区
if (e->NumFramesDirty > 0)
{
XMMATRIX world = XMLoadFloat4x4(&e->World);
ObjectConstants objectConstant;
XMStoreFloat4x4(&objectConstant.world, world);
currObjectCB->CopyData(e->ObjectIndex, objectConstant);
//对下一个帧资源进行更新
e->NumFramesDirty--;
}
}
}
渲染过程常量缓冲区的更新:
void ShapesApp::UpdateMainPassCB(const GameTimer & gt)
{
//获取观察矩阵和投影矩阵
XMMATRIX view = XMLoadFloat4x4(&mView);
XMMATRIX proj = XMLoadFloat4x4(&mProj);
//获取观察投影矩阵
XMMATRIX viewProj = XMMatrixMultiply(view, proj);
//分别获取观察矩阵,投影矩阵、观察投影矩阵的逆矩阵
XMMATRIX invView = XMMatrixInverse(&XMMatrixDeterminant(view), view);
XMMATRIX invProj = XMMatrixInverse(&XMMatrixDeterminant(proj), proj);
XMMATRIX invViewProj = XMMatrixInverse(&XMMatrixDeterminant(viewProj), viewProj);
//更新渲染过程常量缓冲区
XMStoreFloat4x4(&mMainPassCB.View, XMMatrixTranspose(view));
XMStoreFloat4x4(&mMainPassCB.InvView, XMMatrixTranspose(invView));
XMStoreFloat4x4(&mMainPassCB.Proj, XMMatrixTranspose(proj));
XMStoreFloat4x4(&mMainPassCB.InvProj, XMMatrixTranspose(invProj));
XMStoreFloat4x4(&mMainPassCB.ViewProj, XMMatrixTranspose(viewProj));
mMainPassCB.EyePosW = mEyePos;
mMainPassCB.RenderTargetSize = XMFLOAT2((float)mClientWidth, (float)mClientHeight);
mMainPassCB.InvRenderTargetSize = XMFLOAT2(1.0f / mClientWidth, 1.0f / mClientHeight);
mMainPassCB.NearZ = 1.0f;
mMainPassCB.FarZ = 1000.0f;
mMainPassCB.TotalTime = gt.TotalTime;
mMainPassCB.DeltaTime = gt.DeltaTime;
auto currPassCB = mCurrFrameResource->passCB.get();
currPassCB->CopyData(0, mMainPassCB);
}
随着常量缓冲区结构体的变化,我们也要相应的更新顶点着色器:
VertexOut VS(VertexIn vIn)
{
VertexOut vOut;
//将局部空间的顶点坐标变换到齐次裁剪空间(局部-世界-观察-投影和齐次裁剪空间)
float4 PosW = mul(float4(vIn.PosL,1.0f),cbPerObject.gWorld);
vOut.PosH = mul(PosW, cbPass.gViewProj);
//直接向像素着色器输入顶点的颜色数据
vOut.Color = vIn.Color;
return vOut;
}
现在,着色器所期待的输入资源已经发生了改变,所以我们也需要相应的调整根签名来使着色器获取所需的描述符表:
//创建两个常量缓冲区描述符表
CD3DX12_DESCRIPTOR_RANGE cbvTable0;
cbvTable0.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 0);
CD3DX12_DESCRIPTOR_RANGE cbvTable1;
cbvTable1.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 1);
//创建根参数
CD3DX12_ROOT_PARAMETER slotRootParameter[2];
slotRootParameter[0].InitAsDescriptorTable(1, &cbvTable0);
slotRootParameter[1].InitAsDescriptorTable(1, &cbvTable1);
//创建根签名描述符
CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(2, slotRootParameter, 0, nullptr,
D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);
7.4 不同形状的几何体
在本节中,我们将展示如何创建不同形状的几何体(球体、柱体、椭圆体等等),这些几何体对绘制天空穹顶(sky dome),图形程序调试、碰撞检测以及延迟渲染(deferred rendering)是极其有用的。比如在调试检测中,我们可以把正在制作中的游戏角色简化成球体。
我们将程序性几何体(procedural geometry,根据用户提供的参数生成对应的几何体)的代码放到GeometryGenerator文件中,GeometryGenerator是一个工具类,用于生成一些简单的几何体,该工具类将数据生成在系统内存中,而我们必须把这些数据复制到顶点缓冲区和索引缓冲区中。MeshData是一个嵌套在GeometryGenerator类中用于存储顶点列表和索引列表的简易结构体:
class GeometryGenerator
{
public:
using uint16 = std::uint16_t;
using uint32 = std::uint32_t;
struct Vertex
{
Vertex() {};
Vertex(
const DirectX::XMFLOAT3& p,
const DirectX::XMFLOAT3& n,
const DirectX::XMFLOAT3& t,
const DirectX::XMFLOAT2& uv
):Position(p),Normal(n),TangentU(t),TexC(uv)
{
}
Vertex(
float px, float py, float pz,
float nx,float ny,float nz,
float tx,float ty,float tz,
float u,float v
):Position(px,py,pz),Normal(nx,ny,nz),TangentU(tx,ty,tz),TexC(u,v)
{
}
DirectX::XMFLOAT3 Position;
DirectX::XMFLOAT3 Normal;
DirectX::XMFLOAT3 TangentU;
DirectX::XMFLOAT2 TexC;
};
struct MeshData
{
std::vector<Vertex> Vertices;
std::vector<uint32> indices32;
std::vector<uint16>& Getindices16()
{
if (mindices16.empty())
{
mindices16.resize(indices32.size());
for (size_t i = 0; i < indices32.size(); i++)
{
mindices16[i] = static_cast<uint16>(indices32[i]);
}
return mindices16;
}
}
private:
std::vector<uint16> mindices16;
};
……
};
7.4.1 生成柱体网格
在定义一个柱体之前,我们要先指定其顶和底面的半径、高度,切片数量(slice count即将横截面分割的块数)和堆叠层数(stack count)。在程序中,我们会把柱体分为侧面几何体、顶面几何体和底面几何体三部分。
7.4.2 柱体的侧面几何体
下面我们将展示创建侧面几何体的所有顶点数据代码(具体的推导过程暂时不详细解释,基本思路是遍历每一个环,并生成环上的各个顶点):
//该圆台是一个中心(高度1/2出截面的中心点)位于原点,且旋转轴平行于y轴的圆台
GeometryGenerator::MeshData GeometryGenerator::CreateCylinder(float bottomRadius, float topRadius, float height,
uint32 sliceCount, uint32 stackCount)
{
MeshData meshData;
//
//构建堆叠层
//
//计算每一层的高度
float stackHeight = height / stackCount;
//计算从上到下遍历每一个相邻分层的半径增量
float radiusStep = (topRadius - bottomRadius) / stackCount;
//环的数量(两个环构成一层,三个环构成两层……)
uint32 ringCount = stackCount + 1;
//从地面开始,由下到上计算每一个对叠层环上的坐标
for (uint32 i = 0; i < ringCount; i++)
{
float y = -0.5*height + i * stackHeight;
float r = bottomRadius + radiusStep * i;
//环上的各个顶点
float dTheta = 2.0f*XM_PI / sliceCount;
for (uint32 j = 0; j <= sliceCount; ++j)
{
Vertex vertex;
float c = cosf(j*dTheta);
float s = sinf(j*dTheta);
vertex.Position = XMFLOAT3(r*c, y, r*s);
vertex.TexC.x = (float)j / sliceCount;
vertex.TexC.y = 1.0f - (float)i / sliceCount;
//切线的单位长度(顶点方程的导数)
vertex.TangentU = XMFLOAT3(-s, 0.0f, c);
float dr = bottomRadius - topRadius;
XMFLOAT3 bitangent(dr*c, -height, dr*s);
XMVECTOR T = XMLoadFloat3(&vertex.TangentU);
XMVECTOR B = XMLoadFloat3(&bitangent);
//返回T和B的规范化后的外积(叉积、向量积)
XMVECTOR N = XMVector3Normalize(XMVector3Cross(T, B));
XMStoreFloat3(&vertex.Normal, N);
meshData.Vertices.push_back(vertex);
}
}
}
注意点:在上面的代码可以看出,每个环上的第一个顶点和最后一个顶点在位置上是相互重合的,但是二者的纹理坐标不相同,这有这样才能保证在圆台上绘制出正确的纹理。
生成顶点数据之后,我们需要生成侧面几何体的索引数据,下面是生成索引数据的示例代码(此代码接上面的CreateCylinder方法):
//+1是想多创建一个和第一个顶点重合的顶点,具体原因看上面的注意点
uint32 ringVertexCount = sliceCount + 1;
//计算每一个三角形的索引
for (uint32 i = 0; i < stackCount; ++i)
{
for (uint32 j = 0; j < sliceCount; ++j)
{
meshData.indices32.push_back(i*ringVertexCount + j);
meshData.indices32.push_back((i + 1)*ringVertexCount + j);
meshData.indices32.push_back((i + 1)*ringVertexCount + j + 1);
meshData.indices32.push_back(i*ringVertexCount + j);
meshData.indices32.push_back((i + 1)*ringVertexCount + j);
meshData.indices32.push_back(i*ringVertexCount + j + 1);
}
}
BuildCylinderTopCap(bottomRadius, topRadius, height, sliceCount, stackCount, sliceCount);
BuildCylinderBottomCap(bottomRadius, topRadius, height, sliceCount, stackCount, sliceCount);
return meshData;
7.4.1.2 柱体的端面几何体
下面是生成圆台顶点几何体的方法:
void GeometryGenerator::BuildCylinderTopCap(float bottomRaidus, float topRadius, float height,
uint32 sliceCount, uint32 stackCount, GeometryGenerator::MeshData& meshData)
{
uint32 baseIndex = (uint32)meshData.Vertices.size();
float y = 0.5f*height;
float dTheta = 2.0f*XM_PI / sliceCount;
//生成底面环的顶点坐标
for (uint32 i = 0; i < sliceCount; i++)
{
float x = topRadius * cosf(i*dTheta);
float z = topRadius * sinf(i*dTheta);
//根据圆台的高度使顶面纹理坐标的范围按比例缩小
float u = x / height + 0.5f;
float v = z / height + 0.5f;
meshData.Vertices.push_back(Vertex(x, y, z, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, u, v));
}
//顶面的中心顶点的顶点数据
meshData.Vertices.push_back(Vertex(0.0f, y, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.5f, 0.5f));
//中心顶点的索引值
uint32 centerIndex = (uint32)meshData.Vertices.size() - 1;
for (uint32 i = 0; i < sliceCount; ++i)
{
meshData.indices32.push_back(centerIndex);
meshData.indices32.push_back(baseIndex + i + 1);
meshData.indices32.push_back(baseIndex + i);
}
}
生成圆台底面几何体的方法类似于顶面几何体(所以跳过)
7.4.2 生成球体网格
欲定义一个球体,就要指定其半径、切片数量以及堆叠层数,除了每一个环上的半径是依三角函数非线性变化之外,生成球体的算法和生成圆台的算法十分接近。最后说一点,若采用不等比缩放世界矩阵,即可把球体转换为椭球体
7.4.3 生成几何球体网格
为了生成几何球体,我们会以一个正二十面体作为基础,细分上面的三角形,再根据给定的半径向球面投影新生成的顶点。反复这个过程,便可以提高几何球体的曲面细分程度了。下面是相关的代码:
GeometryGenerator::MeshData GeometryGenerator::CreateGeosphere(float radius, uint32 numSubdivisions)
{
MeshData meshData;
//确定细分的次数
numSubdivisions = std::min<uint32>(numSubdivisions, 6u);
//通过对一个正二十面体进行曲面细分来逼近一个球体
const float X = 0.525731f;
const float Z = 0.850651f;
//位置
XMFLOAT3 pos[12] =
{
XMFLOAT3(-X,0.0f,Z),XMFLOAT3(X,0.0f,Z),
XMFLOAT3(-X,0.0f,-Z),XMFLOAT3(X,0.0f,-Z),
XMFLOAT3(0.0f,Z,X),XMFLOAT3(0.0f,Z,-X),
XMFLOAT3(0.0f,-Z,X),XMFLOAT3(0.0f,-Z,-X),
XMFLOAT3(Z,X,0.0f),XMFLOAT3(-Z,X,0.0f),
XMFLOAT3(Z,-X,0.0f),XMFLOAT3(-Z,-X,0.0f)
};
//索引
uint32 k[60] =
{
1,4,0, 4,9,0, 4,5,9, 8,5,4, 1,8,4,
1,10,8, 10,3,8, 8,3,5, 3,2,5, 3,7,2,
3,10,7, 10,6,7, 6,11,7, 6,0,11, 6,1,0,
10,1,6, 11,0,9, 2,11,9, 5,2,9, 11,2,7
};
meshData.Vertices.resize(12);
meshData.indices32.assign(&k[0], &k[60]);
for (uint32 i = 0; i < meshData.Vertices.size(); ++i)
{
meshData.Vertices[i].Position = pos[i];
}
//for (uint32 i = 0; i < numSubdivisions; ++i)
//{
// Subdivide(meshData);
//}
//将每一个顶点投影到表面,并推导出其对应的纹理坐标
for (uint32 i = 0; i < meshData.Vertices.size(); ++i)
{
//返回顶点坐标中位置信息的规范化向量(投影到单位球面上)
XMVECTOR n = XMVector3Normalize(XMLoadFloat3(&meshData.Vertices[i].Position));
//投影到球面上
XMVECTOR p = radius * n;
XMStoreFloat3(&meshData.Vertices[i].Position, p);
XMStoreFloat3(&meshData.Vertices[i].Normal, n);
//根据球面坐标推导出纹理坐标(计算z和x的反正切值)
float theta = atan2f(meshData.Vertices[i].Position.z, meshData.Vertices[i].Position.x);
//将theta的值限制在0到2pi
if (theta < 0.0f)
{
theta += XM_2PI;
}
float phi = acosf(meshData.Vertices[i].Position.y / radius);
meshData.Vertices[i].TexC.x = theta / XM_2PI;
meshData.Vertices[i].TexC.y = phi / XM_PI;
//求出P关于theta的偏导数
meshData.Vertices[i].TangentU.x = -radius * sinf(phi)*sinf(theta);
meshData.Vertices[i].TangentU.y = 0.0f;
meshData.Vertices[i].TangentU.z = +radius * sinf(phi)*cosf(theta);
//对切线进行规范化操作
XMVECTOR T = XMLoadFloat3(&meshData.Vertices[i].TangentU);
XMStoreFloat3(&meshData.Vertices[i].TangentU, XMVector3Normalize(T));
}
return meshData;
}