7.7、陆地与波浪演示程序
在本小节中,我们将展示如何构建“陆地与波浪”演示程序:
- 构建一个三角形栅格(grid),并通过将其中的顶点设置到不同的位置来创建地形
- 再构建一个三角形栅格(grid),通过动态的改变其顶点高度来创建波浪
- 此示例还会针对不同的常量缓冲区来切换所使用的根描述符,这可以让我们不需要设置CBV描述符堆
7.7.1、生成栅格顶点
为了生成地形和波浪,我们首先要构建三角形栅格(grid),这里我们不进行详细的数学分析,直接给出结论:
其中w为栅格的总宽度(宽),dx为每一个小四边形的宽度,d为栅格的总深度(长),dz为每一个小四边形的深度,
(其实可以把栅格理解成一个由许多小四边形组成的大四边形)
下面是生成栅格顶点的代码:
/*
** Summary:生成栅格顶点
** Parameters:
** width:栅格的宽度
** depth:栅格的深度
** m:行数
** n:列数
** Return:栅格的网格数据
*/
GeometryGenerator::MeshData GeometryGenerator::CreateGrid(float width, float depth, uint32 m, uint32 n)
{
//用作返回值的数据
MeshData meshData;
//顶点数量
uint32 vertexCount = m*n;
//三角形数量
uint32 faceCount = (m-1)*(n-1)*2;
//
// 创建顶点
//
float halfWidth = 0.5f*width;
float halfDepth = 0.5f*depth;
float dx = width / (n-1);
float dz = depth / (m-1);
float du = 1.0f / (n-1);
float dv = 1.0f / (m-1);
meshData.Vertices.resize(vertexCount);
for(uint32 i = 0; i < m; ++i)
{
float z = halfDepth - i*dz;
for(uint32 j = 0; j < n; ++j)
{
float x = -halfWidth + j*dx;
meshData.Vertices[i*n+j].Position = XMFLOAT3(x, 0.0f, z);
meshData.Vertices[i*n+j].Normal = XMFLOAT3(0.0f, 1.0f, 0.0f);
meshData.Vertices[i*n+j].TangentU = XMFLOAT3(1.0f, 0.0f, 0.0f);
//在栅格上拉伸纹理
meshData.Vertices[i*n+j].TexC.x = j*du;
meshData.Vertices[i*n+j].TexC.y = i*dv;
}
}
return meshData;
}
7.7.2、生成栅格索引
完成顶点的计算之后,我们需要指定索引来定义栅格三角形。所以我们需要遍历每一个四边形,并计算索引以此来定义构成每一个四边形的三角形。具体看代码:
/*
** Summary:生成栅格顶点
** Parameters:
** width:栅格的宽度
** depth:栅格的深度
** m:行数
** n:列数
** Return:栅格的网格数据
*/
GeometryGenerator::MeshData GeometryGenerator::CreateGrid(float width, float depth, uint32 m, uint32 n)
{
//用作返回值的数据
MeshData meshData;
//顶点数量
uint32 vertexCount = m*n;
//三角形数量
uint32 faceCount = (m-1)*(n-1)*2;
//
// 创建索引
//
//三角形数量*3
meshData.Indices32.resize(faceCount*3);
//遍历每一个四边形计算索引
uint32 k = 0;
for(uint32 i = 0; i < m-1; ++i)
{
for(uint32 j = 0; j < n-1; ++j)
{
meshData.Indices32[k] = i*n+j;
meshData.Indices32[k+1] = i*n+j+1;
meshData.Indices32[k+2] = (i+1)*n+j;
meshData.Indices32[k+3] = (i+1)*n+j;
meshData.Indices32[k+4] = i*n+j+1;
meshData.Indices32[k+5] = (i+1)*n+j+1;
//下一个四边形
k += 6;
}
}
return meshData;
}
7.7.3、应用计算高度的函数
通过前面两节,我们可以创建出一个栅格,栅格创建完毕之后,我们就可以获取栅格的顶点元素,通过设置顶点的高度(y坐标)便可以将平坦的栅格转换成高低起伏的曲面,并为它生成对应的颜色
//顶点结构体
struct Vertex
{
XMFLOAT3 Pos;
XMFLOAT4 Coloe;
}
void LandAndWavesApp::BuildLandGeometry()
{
GeometryGenerator geoGen;
GeometryGenerator::MeshData grid = geoGen.CreateGrid(160.0f, 160.0f, 50, 50);
//
//获取顶点元素,并利用高度函数计算每一个顶点的高度值(y轴值)
//根据不同的高度设置不同的颜色
//
std::vector<Vertex> vertices(grid.Vertices.size());
for(size_t i = 0; i < grid.Vertices.size(); ++i)
{
auto& p = grid.Vertices[i].Position;
vertices[i].Pos = p;
vertices[i].Pos.y = GetHillsHeight(p.x, p.z);
// 基于顶点高度设置颜色
……
const UINT vbByteSize = (UINT)vertices.size() * sizeof(Vertex);
std::vector<std::uint16_t> indices = grid.GetIndices16();
const UINT ibByteSize = (UINT)indices.size() * sizeof(std::uint16_t);
auto geo = std::make_unique<MeshGeometry>();
geo->Name = "landGeo";
//创建缓冲区
ThrowIfFailed(D3DCreateBlob(vbByteSize, &geo->VertexBufferCPU));
//将顶点数据存入geo中
CopyMemory(geo->VertexBufferCPU->GetBufferPointer(), vertices.data(), vbByteSize);
//创建缓冲区
ThrowIfFailed(D3DCreateBlob(ibByteSize, &geo->IndexBufferCPU));
//将索引数据存入geo中
CopyMemory(geo->IndexBufferCPU->GetBufferPointer(), indices.data(), ibByteSize);
geo->VertexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
mCommandList.Get(), vertices.data(), vbByteSize, geo->VertexBufferUploader);
geo->IndexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
mCommandList.Get(), indices.data(), ibByteSize, geo->IndexBufferUploader);
geo->VertexByteStride = sizeof(Vertex);
geo->VertexBufferByteSize = vbByteSize;
geo->IndexFormat = DXGI_FORMAT_R16_UINT;
geo->IndexBufferByteSize = ibByteSize;
SubmeshGeometry submesh;
submesh.IndexCount = (UINT)indices.size();
submesh.StartIndexLocation = 0;
submesh.BaseVertexLocation = 0;
geo->DrawArgs["grid"] = submesh;
mGeometries["landGeo"] = std::move(geo);
}
7.7.4、根常量缓冲区视图
在这个示例程序中,我们将使用根描述符,这样我们就不使用描述符表了,为了使用根描述符,我们需要做一些改动:
- 根签名需要变成需要两个根CBV,而不是两个描述符表
- 不用使用CBV堆吗,也不需要向其填充描述符
- 涉及一种用于绑定根描述符的新语法
新的根签名定义如下:
// 创建根签名
CD3DX12_ROOT_PARAMETER slotRootParameter[2];
// 物体的CBV
slotRootParameter[0].InitAsConstantBufferView(0);
//渲染过程的CBV
slotRootParameter[1].InitAsConstantBufferView(1);
// 创建根签名描述符
CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(2, slotRootParameter, 0, nullptr, D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);
创建完根签名之后,我们需要使用下列方法,以传递参数的形式将CBV和某个根描述符相互绑定
/*
** Summary:将CBv和根描述符相互绑定
** Parameters:
** RootParameterIndex:CBV将要绑定的根参数索引,即寄存器的槽位号
** BufferLocation:含有常量缓冲区数据资源的虚拟地址
** Return:Void
*/
void SetGraphicsRootConstantBufferView(
UINT RootParameterIndex,
D3D12_GPU_VIRTUAL_ADDRESS BufferLocation);
经过上述变化之后,我们的绘制代码会发生一些改变:
void LandAndWavesApp::Draw(const GameTimer& gt)
{
…………
// 绑定渲染过程中所使用的常量缓冲区,在每一个渲染过程中,该代码只需要执行一次
auto passCB = mCurrFrameResource->PassCB->Resource();
mCommandList->SetGraphicsRootConstantBufferView(1, passCB->GetGPUVirtualAddress());
DrawRenderItems(mCommandList.Get(), mRitemLayer[(int)RenderLayer::Opaque]);
…………
}
void LandAndWavesApp::DrawRenderItems(ID3D12GraphicsCommandList* cmdList, const std::vector<RenderItem*>& ritems)
{
UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
auto objectCB = mCurrFrameResource->ObjectCB->Resource();
for(size_t i = 0; i < ritems.size(); ++i)
{
auto ri = ritems[i];
cmdList->IASetVertexBuffers(0, 1, &ri->Geo->VertexBufferView());
cmdList->IASetIndexBuffer(&ri->Geo->IndexBufferView());
cmdList->IASetPrimitiveTopology(ri->PrimitiveType);
D3D12_GPU_VIRTUAL_ADDRESS objCBAddress = objectCB->GetGPUVirtualAddress();
objCBAddress += ri->ObjCBIndex*objCBByteSize;
cmdList->SetGraphicsRootConstantBufferView(0, objCBAddress);
cmdList->DrawIndexedInstanced(ri->IndexCount, 1, ri->StartIndexLocation, ri->BaseVertexLocation, 0);
}
}
7.7.5、动态顶点缓冲区
到目前为止,我们每次都将顶点数据存在默认的资源缓冲区中,然后借此存储静态几何体。所以我们不能动态的改变此资源中存储的几何体,只能一次性设置好数据,然后用GPU读取其中的数据并进行绘制。
因此,为了改变这个窘境,一种名为动态缓冲区的资源便出现了,它允许用户频繁的改变顶点数据,比如我们在模拟波浪的时候,我们需要随着时间的变化而不断更新顶点的高度,因此我们需要使用动态顶点缓冲区。或者是粒子系统中,我们需要寻找每一个粒子的新位置,而且在每一帧都要使用CPU进行物理模拟计算和碰撞检测,这个时候我们也需要使用动态顶点缓冲区在绘制每一帧的时候更新粒子的位置
在通过上传缓冲区来更新常量缓冲区中的数据的小节中,我们已经接触过如何在每一帧通过CPU来向GPU上传数据的具体流程,现在我们只要将常量缓冲区换成顶点数组就可以实现动态顶点缓冲区了。
std::unique_ptr<UploadBuffer<Vertex>> WavesVB = nullptr;
WavesVB = std::make_unique<UploadBuffer<Vertex>>(device, waveVertCount, false);
由于我们每一帧都要从CPU中向动态顶点缓冲区上传新的数据,所以我们需要将动态顶点缓冲区存为一种帧资源,否则,我们可能在GPU没有完成上一帧的处理,就修改了相关的内存了。
在每一帧中,我们都使用下列方式来模拟波浪并更新顶点缓冲区
void LandAndWavesApp::UpdateWaves(const GameTimer& gt)
{
// 每隔1/4秒生成一个随机波浪
static float t_base = 0.0f;
if((mTimer.TotalTime() - t_base) >= 0.25f)
{
t_base += 0.25f;
int i = MathHelper::Rand(4, mWaves->RowCount() - 5);
int j = MathHelper::Rand(4, mWaves->ColumnCount() - 5);
float r = MathHelper::RandF(0.2f, 0.5f);
mWaves->Disturb(i, j, r);
}
// 更新模拟的波浪
mWaves->Update(gt.DeltaTime());
// 更新波浪顶点缓冲区
auto currWavesVB = mCurrFrameResource->WavesVB.get();
for(int i = 0; i < mWaves->VertexCount(); ++i)
{
Vertex v;
v.Pos = mWaves->Position(i);
v.Color = XMFLOAT4(DirectX::Colors::Blue);
currWavesVB->CopyData(i, v);
}
// 将波浪渲染项的动态顶点缓冲区设置到当前帧的顶点缓冲区
mWavesRitem->Geo->VertexBufferGPU = currWavesVB->Resource();
}
使用动态缓冲区时会不可避免的产生一些额外的开销,因为新的数据必须从CPU端内存回传到GPU端显存,所以,如果静态缓冲区可以实现和动态缓冲区同样的工作,我们应该避免使用动态缓冲区。本章示例程序主要展示了如何通过一个动态顶点缓冲区来实现波浪的模拟。(题外话:只要将顶点数组改成索引数组,我们便可以创建一个动态索引缓冲区了,这方面可以自己扩展一下)
7.7.6、示例程序运行效果
7.8 、小结
1、在每一帧中等待GPU完成命令队列中的命令执行完毕的做法是效率极低的,所以我们需要创建帧资源(由每一帧都需要CPU来修改的资源所构成的环形数组)。有了帧资源之后,CPU便可以不需要等待GPU完成命令队列中的所有命令,就开始执行下一帧的相关工作了。
2、当CPU的速度总是快于GPU时,这样的话即使使用帧资源,CPU也会有等待GPU的时间存在,不过这是好事,因为我们可以用这一部分等待的时间处理游戏的其他部分,比如:AI,物理模拟,游戏逻辑等等
3、我们可以使用ID3D12DescriptorHeap::GetCPUDescriptorHandleForHeapStart方法来获取堆中的第一个描述符的句柄,然后通过ID3D12Device::GetDescriptorHandleIncrementSize方法来获取描述符的大小(依赖于硬件和描述符的类型)。知道描述符增量的大小之后,我们便可以通过下列方式将句柄从第一个描述符的位置偏移到第n个描述符的位置了:
//指定要偏移到的描述符编号,再将它和描述符增量的大小相乘
D3D12_CPU_DESCRIPTOR_HANDLE handle = mCBvHeap->
GetCPUDescriptorHandleForHeapStart();
handle.Offset(n * mCbvSrvDescriptorSize);
//另一种等价实现的方法
D3D12_CPU_DESCRIPTOR_HANDLE handle = mCBvHeap->
GetCPUDescriptorHandleForHeapStart();
handle.Offset(n, mCbvSrvDescriptorSize);
4、根签名定义了在绘制调用开始之前需要和渲染流水线相互绑定的资源,以及这些资源将被映射到的具体着色器输入寄存器。绑定到流水线的具体资源要根据着色器程序来确定。根签名由一系列根参数构成,根参数可以是描述符表,根描述符或根常量,处于性能的原因,一个根签名中所能容纳的数据大小最多为64DWORD。
根参数组成元素 | 描述 |
---|---|
描述符表 | 描述符表在堆中指定了一块描述符的连续范围,每一个描述符表占1DWORD |
根描述符 | 根描述符用于直接绑定根签名中的描述符(无需涉及描述符堆),占2DWORD |
根常量 | 根常量用于直接绑定根签名中的常量数据。每个32位的根常量占1DWORD |
5、当顶点缓冲区需要在运行时频繁的更新时,我们就可以使用动态顶点缓冲区了,我们可以使用UploadBuffer类来实现动态顶点缓冲区,只需要将存储的内容从常量缓冲区数组换成顶点数组即可,不过使用动态顶点缓冲区会产生一些额外的开销,因为数据必须从CPU端内存回传到GPU端显存,因此,如果可以使用静态缓冲区完成的工作,就不要使用动态缓冲区了。而且,Direct3D的最新版本也引进了一些新的特性,以此减少动态缓冲区的使用。