今天我们继续搞定"山川湖泊"。在上篇已经创建了"山川",这篇来创建"湖泊"模型和波纹涟漪动画。
如何让“湖泊”产生波浪涟漪效果呢?最直观的想法就是不断改变顶点坐标,只要改变一次所需的时间越短,那这个顶点动画看起来就会越平滑。那如何不断改变顶点坐标呢?我们可以通过如渲染到纹理,计算着色器以及顶点纹理拾取等在GPU上计算的方法,但这些方法涉及比较高级的应用,我们在深入之后章节会学习。今天我们使用CPU计算的方法,每帧(或者每几帧)改变顶点缓冲区中顶点的坐标,实现顶点动画效果,这就涉及到动态顶点缓冲区了。还记得我们之前是如何每帧更新常量缓冲区的吗?我们封装了UploadBuffer类来实现的,因为上传堆是可以被CPU写入的,并且封装时也做了非常量缓冲区的扩展,所以我们可以使用这个类来实现动态顶点缓冲区。
1.MeshGeometry类
现在的问题是,“山川”模型没有动画,是静态的,顶点数据存在默认堆中(当然,具体实现是先存上传堆再传至默认堆)。而“湖泊”模型又是动态的,顶点数据存在上传堆中。因为所处不同类型的堆,所以我们就不能共用vertexBufferGpu和indexBufferGpu,于是我们封装一个小结构,专门存放每个几何体顶点数据在传输过程中用到的CPU系统内存和GPU缓存的指针变量,并将顶点缓冲区描述符(vbv)和索引缓冲区描述符(ibv)的创建结构体也放入其中(因为需要用到堆地址)。我们将这个结构命名为MeshGeometry。
struct SubmeshGeometry
{
UINT indexCount;
UINT startIndexLocation;
UINT baseVertexLocation;
};
struct MeshGeometry
{
std::string name;
ComPtr<ID3DBlob> vertexBufferCpu = nullptr; //CPU系统内存上的顶点数据
ComPtr<ID3D12Resource> vertexBufferUploader = nullptr; //GPU上传堆中的顶点缓冲区
ComPtr<ID3D12Resource> vertexBufferGpu = nullptr; //GPU默认堆中的顶点缓冲区(最终的GPU顶点缓冲区)
ComPtr<ID3DBlob> indexBufferCpu = nullptr; //CPU系统内存上的索引数据
ComPtr<ID3D12Resource> indexBufferUploader = nullptr; //GPU上传堆中的索引缓冲区
ComPtr<ID3D12Resource> indexBufferGpu = nullptr; //GPU默认堆中的索引缓冲区(最终的GPU索引缓冲区)
UINT vertexBufferByteSize = 0;
UINT vertexByteStride = 0;
UINT indexBufferByteSize = 0;
DXGI_FORMAT indexFormat = DXGI_FORMAT_R16_UINT;
std::unordered_map<std::string, SubmeshGeometry> DrawArgs;//对应不同子物体绘制三参数的无序列表
D3D12_VERTEX_BUFFER_VIEW GetVbv()const
{
D3D12_VERTEX_BUFFER_VIEW vbv;
vbv.BufferLocation = vertexBufferGpu->GetGPUVirtualAddress();//顶点缓冲区资源虚拟地址
vbv.SizeInBytes = vertexBufferByteSize; //顶点缓冲区大小(所有顶点数据大小)
vbv.StrideInBytes = vertexByteStride; //每个顶点元素所占用的字节数
return vbv;
}
D3D12_INDEX_BUFFER_VIEW GetIbv() const
{
D3D12_INDEX_BUFFER_VIEW ibv;
ibv.BufferLocation = indexBufferGpu->GetGPUVirtualAddress();
ibv.Format = indexFormat;
ibv.SizeInBytes = indexBufferByteSize;
return ibv;
}
//等上传堆资源传至默认堆后,释放上传堆里的内存
void DisposeUploaders()
{
vertexBufferUploader = nullptr;
indexBufferUploader = nullptr;
}
};
接着我们在RenderItem结构体中加入MeshGeometry类指针,因为RenderItem是MeshGeometry的上层结构。
struct RenderItem
{
......
MeshGeometry* geo = nullptr;
......
};
然后我们修改“山川”模型的顶点和索引列表代码,将顶点数据赋值MeshGeometry类中对应变量。注意:在最后我们将grid的绘制三参数放入一个映射表,再将grid的MeshGeometry放入另一个映射表,这样我们就能将山川和湖泊的顶点数据区分开来了。
std::unordered_map<std::string, std::unique_ptr<MeshGeometry>> geometries;
//创建索引缓存
std::vector<std::uint16_t> indices = grid.GetIndices16();
//计算顶点缓存和索引缓存大小
//MeshGeometry* geo = nullptr;
auto geo = std::make_unique<MeshGeometry>();
geo->name = "landGeo";
const UINT vbByteSize = (UINT)vertices.size() * sizeof(Vertex);
const UINT ibByteSize = (UINT)indices.size() * sizeof(std::uint16_t);
geo->vertexBufferByteSize = vbByteSize;
geo->indexBufferByteSize = ibByteSize;
geo->vertexByteStride = sizeof(Vertex);
geo->indexFormat = DXGI_FORMAT_R16_UINT;
//顶点、索引数据传至GPU缓存
ThrowIfFailed(D3DCreateBlob(vbByteSize, &geo->vertexBufferCpu)); //创建顶点数据内存空间
ThrowIfFailed(D3DCreateBlob(ibByteSize, &geo->indexBufferCpu)); //创建索引数据内存空间
CopyMemory(geo->vertexBufferCpu->GetBufferPointer(), vertices.data(), vbByteSize); //将顶点数据拷贝至顶点系统内存中
CopyMemory(geo->indexBufferCpu->GetBufferPointer(), indices.data(), ibByteSize); //将索引数据拷贝至索引系统内存中
geo->vertexBufferGpu = ToolFunc::CreateDefaultBuffer(d3dDevice.Get(), cmdList.Get(), vbByteSize, vertices.data(), geo->vertexBufferUploader);
geo->indexBufferGpu = ToolFunc::CreateDefaultBuffer(d3dDevice.Get(), cmdList.Get(), ibByteSize, indices.data(), geo->indexBufferUploader);
//将之前封装好的几何体的SubmeshGeometry对象赋值给无序映射表
geo->DrawArgs["landGrid"] = gridSubmesh;
//将“山川”的MeshGeometry装入总的几何体映射表
geometries["landGeo"] = std::move(geo);
接着,我们实现BuildRenderItem的“山川”部分。利用上面创建好的无序表landGeo赋值当前MeshGeoetry,然后就可以使用对应的MeshGeoetry里面的数据了。最后将“山川”渲染项放入总渲染项中。
auto landRitem = std::make_unique<RenderItem>();
landRitem->world = MathHelper::Identity4x4();
landRitem->objCBIndex = 0;//grid常量数据(world矩阵)在objConstantBuffer索引0上
landRitem->geo = geometries["landGeo"].get(); //赋值当前MeshGeoetry
landRitem->primitiveType = D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
landRitem->indexCount = landRitem->geo->DrawArgs["landGrid"].indexCount;
landRitem->baseVertexLocation = landRitem->geo->DrawArgs["landGrid"].baseVertexLocation;
landRitem->startIndexLocation = landRitem->geo->DrawArgs["landGrid"].startIndexLocation;
allRitems.push_back(std::move(landRitem));
2.波动算法
具体网格上的顶点是怎样运动的呢?这就涉及到波动算法,最经典的当然是波动方程了,但是我们本篇不会涉及具体的算法,只是看下大致的框架。我们新建一个Waves.h文件,并创建一个Waves类,Disturb函数就是波动方程函数,而Update函数则是每帧更新波动方程计算出来的顶点坐标。
#pragma once
#ifndef WAVES_H
#define WAVES_H
#include <vector>
#include <DirectXMath.h>
//执行波浪模拟的计算。在之后进行的模拟更新后,客户端必须将当前解决方案复制到顶点缓冲区中进行渲染。
class Waves
{
public:
Waves(int m, int n, float dx, float dt, float speed, float damping);
Waves(const Waves& rhs) = delete;
Waves& operator=(const Waves& rhs) = delete;
~Waves();
int RowCount()const;
int ColumnCount()const;
int VertexCount()const;
int TriangleCount()const;
float Width()const;
float Depth()const;
// 返回计算后的网格顶点坐标
const DirectX::XMFLOAT3& Position(int i)const { return mCurrSolution[i]; }
// 返回计算后的网格顶点法线
const DirectX::XMFLOAT3& Normal(int i)const { return mNormals[i]; }
// 返回计算后的网格顶点切线
const DirectX::XMFLOAT3& TangentX(int i)const { return mTangentX[i]; }
void Update(float dt);
void Disturb(int i, int j, float magnitude);
private:
int mNumRows = 0;
int mNumCols = 0;
int mVertexCount = 0;
int mTriangleCount = 0;
// Simulation constants we can precompute.
float mK1 = 0.0f;
float mK2 = 0.0f;
float mK3 = 0.0f;
float mTimeStep = 0.0f;
float mSpatialStep = 0.0f;
std::vector<DirectX::XMFLOAT3> mPrevSolution;
std::vector<DirectX::XMFLOAT3> mCurrSolution;
std::vector<DirectX::XMFLOAT3> mNormals;
std::vector<DirectX::XMFLOAT3> mTangentX;
};
#endif // WAVES_H
3.创建并每帧更新顶点动态缓冲区
上面提到,我们使用UploadBuffer类来实现动态缓冲区的创建,并且应该在帧资源里创建,因为每个帧资源有自己的commandAllocator,每帧都会重置它,所以为了避免缓冲区资源被提前释放,要把顶点动态缓冲区创建放在帧资源里。可以看到wavesVB就是我们的顶点动态缓冲区了,由于它不属于常量缓冲,所以第三个参数是false。
FrameResources::FrameResources(ID3D12Device* device, UINT passCount, UINT objCount, UINT wavesVertCount)
{
ThrowIfFailed(device->CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE_DIRECT,
IID_PPV_ARGS(&cmdAllocator)));
objCB = std::make_unique<UploadBufferResource<ObjectConstants>>(device, objCount, true);
passCB = std::make_unique<UploadBufferResource<PassConstants>>(device, passCount, true);
wavesVB = std::make_unique<UploadBufferResource<Vertex>>(device, wavesVertCount, false);//一个顶点即一个子缓冲区
}
然后我们修改BuildFrameResources函数,加入第4个参数,即顶点动态缓冲区的子缓冲区数量(等于顶点数)。
void LandAndWave::BuildFrameResources()
{
for (int i = 0; i < frameResourcesCount; i++)
{
FrameResourcesArray.push_back(std::make_unique<FrameResources>(
d3dDevice.Get(),
1, //pass子缓冲数量
(UINT)allRitems.size(),//子物体缓冲数量
waves->VertexCount()));//顶点子缓冲数量
}
}
然后我们要在主文件的Init函数中创初始化wave类中数据,实例化“湖泊”模型,并准备好波动数据。
bool LandAndWave::Init(HINSTANCE hInstance, int nShowCmd, std::wstring customCaption)
{
......
//初始化wave类
waves = std::make_unique<Waves>(128, 128, 1.0f, 0.03f, 4.0f, 0.2f);
......
}
然后我们新建一个UpdateWaves函数,来更新每帧的顶点数据。可以看到我们先用波动方程计算出波动的位置和大小,然后更新顶点数据,最后将更新的顶点数据存入CPU系统内存并最终传至GPU的上传堆中。
void LandAndWave::UpdateWaves(const GameTime& gt)
{
static float t_base = 0.0f;
if ((gameTime.TotalTime() - t_base) >= 0.25f)
{
t_base += 0.25f; //0.25秒生成一个波浪
//随机生成横坐标
int i = MathHelper::Rand(4, waves->RowCount() - 5);
//随机生成纵坐标
int j = MathHelper::Rand(4, waves->ColumnCount() - 5);
//随机生成波的半径
float r = MathHelper::RandF(0.2f, 0.5f);//float用RandF函数
//使用波动方程函数生成波纹
waves->Disturb(i, j, r);
}
//每帧更新波浪模拟(即更新顶点坐标)
waves->Update(gt.DeltaTime());
//将更新的顶点坐标存入GPU上传堆中
auto currWavesVB = currFrameResources->wavesVB.get();
for (int i = 0; i < waves->VertexCount(); i++)
{
Vertex v;
v.Pos = waves->Position(i);
v.Color = XMCOLOR(DirectX::Colors::Blue);
currWavesVB->CopyData(i, v);
}
//赋值湖泊的GPU上的顶点缓存
wavesRitem->geo->vertexBufferGpu = currWavesVB->Resource();
}
4.构建“湖泊”索引缓冲区
由于我们的波动只是顶点坐标的改变,并不会改变顶点的索引,所以索引缓存还是静态的。我们用之前的静态存储即可。可以看到,我们先运用grid的程序算法,得到索引列表,并将索引列表存入CPU系统内存,接着使用CreateDefaultBuffer函数经上传堆传入默认堆中。这些代码我们已经很熟悉了,之前一直是这么做的。最后赋值对应MeshGeometry类中的数据(类似于上面的landGeo)。注意:我们规定了索引数不能大于65536,否则中止程序。
void LandAndWave::BuildLakeIndexBuffer()
{
//初始化索引列表(每个三角形3个索引)
std::vector<std::uint16_t> indices(3 * waves->TriangleCount());
assert(waves->VertexCount() < 0x0000ffff);//顶点索引数大于65536则中止程序
//填充索引列表
int m = waves->RowCount();
int n = waves->ColumnCount();
int k = 0;
for (int i = 0; i < m - 1; i++)
{
for (int j = 0; j < n - 1; j++)
{
indices[k] = i * n + j;
indices[k + 1] = i * n + j + 1;
indices[k + 2] = (i + 1) * n + j;
indices[k + 3] = (i + 1) * n + j;
indices[k + 4] = i * n + j + 1;
indices[k + 5] = (i + 1) * n + j + 1;
k += 6;
}
}
//计算顶点和索引缓存大小
UINT vbByteSize = waves->VertexCount() * sizeof(Vertex);
UINT ibByteSize = (UINT)indices.size() * sizeof(std::uint16_t);
auto geo = std::make_unique<MeshGeometry>();
geo->name = "lakeGeo";
geo->vertexBufferCpu = nullptr;
geo->vertexBufferGpu = nullptr;
//创建索引的CPU系统内存
ThrowIfFailed(D3DCreateBlob(ibByteSize, &geo->indexBufferCpu));
//将索引列表存入CPU系统内存
CopyMemory(geo->indexBufferCpu->GetBufferPointer(), indices.data(), ibByteSize);
//将索引数据通过上传堆传至默认堆
geo->indexBufferGpu = ToolFunc::CreateDefaultBuffer(d3dDevice.Get(),
cmdList.Get(),
ibByteSize,
indices.data(),
geo->indexBufferUploader);
//赋值MeshGeomety中相关属性
geo->vertexBufferByteSize = vbByteSize;
geo->vertexByteStride = sizeof(Vertex);
geo->indexFormat = DXGI_FORMAT_R16_UINT;
geo->indexBufferByteSize = ibByteSize;
SubmeshGeometry LakeSubmesh;
LakeSubmesh.baseVertexLocation = 0;
LakeSubmesh.startIndexLocation = 0;
LakeSubmesh.indexCount = (UINT)indices.size();
//使用grid几何体
geo->DrawArgs["lakeGrid"] = LakeSubmesh;
//湖泊的MeshGeometry
geometries["lakeGeo"] = std::move(geo);
}
5.构建渲染项
之前已经实现了渲染项中的“山川”部分,这里再加入“湖泊”部分。记得将objCBIndex改为1,因为是第2个模型。
void LandAndWave::BuildRenderItem()
{
//"山川"部分
......
//"湖泊"部分
auto lakeRitem = std::make_unique<RenderItem>();
lakeRitem->world = MathHelper::Identity4x4();
lakeRitem->objCBIndex = 1;//湖泊的常量数据(world矩阵)在objConstantBuffer索引1上
lakeRitem->geo = geometries["lakeGeo"].get(); //赋值当前的“湖泊”MeshGeoetry
lakeRitem->primitiveType = D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
lakeRitem->indexCount = lakeRitem->geo->DrawArgs["lakeGrid"].indexCount;
lakeRitem->baseVertexLocation = lakeRitem->geo->DrawArgs["lakeGrid"].baseVertexLocation;
lakeRitem->startIndexLocation = lakeRitem->geo->DrawArgs["lakeGrid"].startIndexLocation;
wavesRitem = lakeRitem.get();
allRitems.push_back(std::move(lakeRitem));//push_back后,智能指针自动释放内存
}
编译运行,可以看到,湖面已经动起来了。
由于现在没有光照,动画效果不太明显,看下线框模式的效果。
我们实现了顶点动态缓冲区的数据更新。这个方法相对复杂,而且因为是CPU计算的,所以效率也比较低,但在诸如物理模拟计算和碰撞检测的粒子系统等领域,还是会比较多得用到动态缓冲区。好了,到今天为止我们的绘制几何体部分就结束了,完成习题后,我们就要开启光照篇了。