目录
12、多Slot上传顶点数据
在网格数据加载及上传至GPU上,本章示例应用到了一个重要的D3D12技巧——多Slot(通道)上传(加载)数据(该技巧在OpenGL中同样重要)。
12.1、多Slot上传数据基本原理
首先,多Slot上传数据,是基于一些基础条件的:
1、原始网格数据是按照数据类型分组存储的,即将原来定义在一个结构体中的顶点数据数组,横向拆开,变成每个或某几个属性一个数组的形式:
2、硬件层面具备多通道,现代显卡都是基于PCI-e接口的,并且可以最多利用16x个高速通道,这种接口的天然优势就是具有多通道并行传输数据的能力;
3、编程接口支持多通道,现代的OpenGL及D3D接口都支持多通道传输;
4、多通道传输数据不能过渡影响Shader的编写,应只是程序中的一种方法;这一点在第一条配图中也可以看出来,Shader中的Vertex结构没有任何变换,Vertex Shader也就不用进行修改。
现在上述这些基础条件早就已经非常成熟了!
12.2、Assimp中间数据的简单转换
之所以需要多插槽(Slot 通道)上传数据到GPU(就是共享内存或显存),还有一方面原因,就是现代的大多数模型文件中其实数据都是按横向数组那样存储的,就是介绍原理时的那个图示中右下方的形式:
这样存储的好处很多,主要的就是如果想为模型顶点加上比如切线(tangent)这样的数据时非常的方便高效,不需要遍历顶点结构体挨个按位置进行数组元素插入,只需要追加一个数组到数据中即可,使用时按通道传输数据即可。
本例中,使用Assimp导入数据后,继续简单定义了如下结构体,来作为转换数据结构:
// 模型中子网格的顶点偏移等信息
struct ST_GRS_SUBMESH_DATA
{
UINT m_nNumIndices;
UINT m_nBaseVertex;
UINT m_nBaseIndex;
UINT m_nMaterialIndex;
};
typedef CAtlArray<ST_GRS_SUBMESH_DATA> CGRSSubMesh;
const UINT g_ncSlotCnt = 4; // 用4个插槽上传顶点数据
struct ST_GRS_MESH_DATA
{
const aiScene* m_paiModel;
CStringA m_strFileName;
XMMATRIX m_mxModel;
CGRSSubMesh m_arSubMeshInfo;
CGRSARPositions m_arPositions;
CGRSARNormals m_arNormals;
CGRSARTexCoords m_arTexCoords;
CGRSARVertexBones m_arBoneIndices;
CGRSARIndices m_arIndices;
CGRSMapString2UINT m_mapTextrueName2Index;
CGRSMapUINT2UINT m_mapTextureIndex2HeapIndex;
CGRSARBoneDatas m_arBoneDatas;
CGRSMapString2UINT m_mapName2Bone; //名称->骨骼的索引
CGRSMapString2UINT m_mapAnimName2Index; //名称->动画的索引
UINT m_nCurrentAnimIndex; // 当前播放的动画序列索引(当前动作)
};
这两个结构体定义的总体思路是将一个模型文件中所有的子网格顶点数据都分组连续存储在几个数组中(CGRSARPositions m_arPositions等5个数组),并且用ST_GRS_SUBMESH_DATA结构体数组来对应每个子网格数据在数组中的起始位置和长度信息(CGRSSubMesh m_arSubMeshInfo;中存储)。
另外用两个映射(map:CGRSMapString2UINT m_mapTextrueName2Index;CGRSMapUINT2UINT m_mapTextureIndex2HeapIndex;)来标识出每个子网格对应的纹理索引以及对应描述符索引。
有了结构体,下一步就是组织代码把数据稍作转换,变换到我们的结构体中,这正好就是一开始我们说的LoadMesh函数做的事情:
BOOL LoadMesh(LPCSTR pszFileName, ST_GRS_MESH_DATA& stMeshData)
{
stMeshData.m_nCurrentAnimIndex = 0;
stMeshData.m_paiModel = g_aiImporter.ReadFile(pszFileName, ASSIMP_LOAD_FLAGS);
if (nullptr == stMeshData.m_paiModel)
{
ATLTRACE("无法解析文件(%s):%s (%d)\\n", pszFileName, g_aiImporter.GetErrorString(), ::GetLastError());
return FALSE;
}
// 获取根节点的变换矩阵,其实就是 Module->World 的变换矩阵
stMeshData.m_mxModel = XMMatrixTranspose(MXEqual(stMeshData.m_mxModel, stMeshData.m_paiModel->mRootNode->mTransformation));
// 获取网格数量
UINT nMeshCnt = stMeshData.m_paiModel->mNumMeshes;
if ( 0 == nMeshCnt )
{
ATLTRACE("文件(%s)中没有网格数据!\\n", pszFileName);
return FALSE;
}
const aiMesh* paiSubMesh = nullptr;
const aiVector3D Zero3D(0.0f, 0.0f, 0.0f);
UINT nNumBones = 0;
UINT nNumVertices = 0;
UINT nNumIndices = 0;
stMeshData.m_arSubMeshInfo.SetCount(nMeshCnt);
// 加载Vertex基本信息
for (UINT i = 0; i < nMeshCnt; i++)
{
paiSubMesh = stMeshData.m_paiModel->mMeshes[i];
stMeshData.m_arSubMeshInfo[i].m_nMaterialIndex = paiSubMesh->mMaterialIndex;
stMeshData.m_arSubMeshInfo[i].m_nNumIndices = paiSubMesh->mNumFaces * GRS_INDICES_PER_FACE;
stMeshData.m_arSubMeshInfo[i].m_nBaseVertex = nNumVertices;
stMeshData.m_arSubMeshInfo[i].m_nBaseIndex = nNumIndices;
// 当前Mesh的顶点数量和索引数量累加后,就是下个Mesh顶点和索引在整体缓冲中的索引开始位置
nNumVertices += stMeshData.m_paiModel->mMeshes[i]->mNumVertices;
nNumIndices += stMeshData.m_arSubMeshInfo[i].m_nNumIndices;
// 加载顶点常规数据
for (UINT j = 0; j < paiSubMesh->mNumVertices; j++)
{
stMeshData.m_arPositions.Add(XMFLOAT4(paiSubMesh->mVertices[j].x
, paiSubMesh->mVertices[j].y
, paiSubMesh->mVertices[j].z
, 1.0f));
stMeshData.m_arNormals.Add(XMFLOAT4(paiSubMesh->mNormals[j].x
, paiSubMesh->mNormals[j].y
, paiSubMesh->mNormals[j].z
, 0.0f));
// 注意这个地方只考虑一个纹理的情况,其实最多可以有八个,可以再做个循环进行加载
const aiVector3D* pTexCoord = paiSubMesh->HasTextureCoords(0)
? &(paiSubMesh->mTextureCoords[0][j])
: &Zero3D;
stMeshData.m_arTexCoords.Add(XMFLOAT2(pTexCoord->x, pTexCoord->y));
}
// 加载索引数据
for (UINT j = 0; j < paiSubMesh->mNumFaces; j++)
{
const aiFace& Face = paiSubMesh->mFaces[j];
// 已经通过导入标志强制为三角形网格了,每个面就三个索引
ATLASSERT(Face.mNumIndices == GRS_INDICES_PER_FACE);
for (UINT k = 0; k < Face.mNumIndices; k++)
{
stMeshData.m_arIndices.Add(Face.mIndices[k]);
}
}
}
stMeshData.m_arBoneIndices.SetCount(nNumVertices);
UINT VertexID = 0;
FLOAT Weight = 0.0f;
UINT nBoneIndex = 0;
CStringA strBoneName;
aiMatrix4x4 mxBoneOffset;
aiBone* pBone = nullptr;
// 加载骨骼数据
for (UINT i = 0; i < nMeshCnt; i++)
{
paiSubMesh = stMeshData.m_paiModel->mMeshes[i];
for (UINT j = 0; j < paiSubMesh->mNumBones; j++)
{
nBoneIndex = 0;
pBone = paiSubMesh->mBones[j];
strBoneName = pBone->mName.data;
if ( nullptr == stMeshData.m_mapName2Bone.Lookup(strBoneName) )
{
// 新骨头索引
nBoneIndex = nNumBones ++;
stMeshData.m_arBoneDatas.SetCount(nNumBones);
stMeshData.m_arBoneDatas[nBoneIndex].m_mxBoneOffset
= XMMatrixTranspose(MXEqual(stMeshData.m_arBoneDatas[nBoneIndex].m_mxBoneOffset, pBone->mOffsetMatrix));
stMeshData.m_mapName2Bone.SetAt(strBoneName, nBoneIndex);
}
else
{
nBoneIndex = stMeshData.m_mapName2Bone[strBoneName];
}
for (UINT k = 0; k < pBone->mNumWeights; k++)
{
VertexID = stMeshData.m_arSubMeshInfo[i].m_nBaseVertex + pBone->mWeights[k].mVertexId;
Weight = pBone->mWeights[k].mWeight;
stMeshData.m_arBoneIndices[VertexID].AddBoneData(nBoneIndex, Weight);
}
}
}
// 获取材质数量
UINT nMatCnt = stMeshData.m_paiModel->mNumMaterials;
UINT nTextureIndex = 0;
UINT nTmpIndex = 0;
CStringA strTextureFileName;
aiString aistrPath;
for (UINT i = 0; i < stMeshData.m_paiModel->mNumMaterials; i++)
{
const aiMaterial* pMaterial = stMeshData.m_paiModel->mMaterials[i];
if (pMaterial->GetTextureCount(aiTextureType_DIFFUSE) > 0)
{
if ( pMaterial->GetTexture(aiTextureType_DIFFUSE
, 0, &aistrPath, nullptr, nullptr, nullptr, nullptr, nullptr)
== AI_SUCCESS )
{
strTextureFileName = aistrPath.C_Str();
nTmpIndex = 0;
if ( !stMeshData.m_mapTextrueName2Index.Lookup( strTextureFileName , nTmpIndex ) )
{
stMeshData.m_mapTextrueName2Index.SetAt( strTextureFileName, nTextureIndex );
nTmpIndex = nTextureIndex;
++ nTextureIndex;
}
stMeshData.m_mapTextureIndex2HeapIndex.SetAt( i, nTmpIndex );
}
}
}
return TRUE;
}
这个函数没什么复杂的逻辑,就不过多赘述了。
最后,这样的设计就是暗示大家可以将加载模型数据的整个过程设计成一个Builder模式。当然按照本系列教程的一贯风格,就不过多讨论封装设计的问题了,只是放个图,启发一下思路:
12.2、Layout的定义
其次,在D3D12中就不能再简单的通过定义与shader中对应的顶点结构的方式来组织和上传数据到GPU了,具体的只需要按照D3D12_INPUT_ELEMENT_DESC结构体的要求,结合数据自身的结构特点定义具体的数据上传布局。D3D12_INPUT_ELEMENT_DESC结构体的定义如下:
typedef struct D3D12_INPUT_ELEMENT_DESC
{
LPCSTR SemanticName;
UINT SemanticIndex;
DXGI_FORMAT Format;
UINT InputSlot;
UINT AlignedByteOffset;
D3D12_INPUT_CLASSIFICATION InputSlotClass;
UINT InstanceDataStepRate;
} D3D12_INPUT_ELEMENT_DESC;
其定义中InputSlot即是指定当前记录使用哪个插槽(Slot)来传输数据(理解为通道更恰当一些)。
在本例中具体定义Layout如下:
// 定义传入管线的数据结构,这里使用了多Slot方式,注意Slot的用法
D3D12_INPUT_ELEMENT_DESC stIALayoutSphere[] =
{
{ "POSITION", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "NORMAL", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 2, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "BLENDINDICES", 0, DXGI_FORMAT_R32G32B32A32_UINT, 3, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "BLENDWEIGHT", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 3, 16, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }
};
上面的代码片段中,可以看到整齐排列的第四列,按顺序指定了对应的Slot号,此时第五列的含义就是相对于当前Slot中的偏移量了,因此前4个偏移量就都是从头开始的,就是0了,也就是没有偏移的意思。而第五条记录中,因为与第四条记录使用了相同的插槽,所以偏移就是第四条记录表示的数据长度。
同时,第一列中除了常见的位置、法线、纹理坐标这三个语义外,还增加了BLENDINDICES、BLENDWEIGHT两个新的语义,其实就是代表顶点数据中的骨骼绑定需要的“骨骼索引”和“受影响权重”。对应的Shader中的Vertex Shader输入参数结构(也就是顶点数据结构)定义如下(注意语义和数据类型及向量大小):
struct VSInput
{
float4 position : POSITION0; //顶点位置
float4 normal : NORMAL0; //法线
float2 texuv : TEXCOORD0; //纹理坐标
uint4 bonesID : BLENDINDICES0; //骨骼索引
float4 fWeights : BLENDWEIGHT0; //骨骼权重
};
12.3、缓冲区准备
准备完了合适的数据,并且定义完对应的Layout,接着就是创建数据的缓冲区。因为这次我们使用的是多Slot上传数据,所以在对应的顶点数据缓冲区的创建上也是有对应的特殊处理的,核心的思想就是不能再像以前那样按照一个结构体数组的形式创建一个Vertex Buffer和一个Index Buffer就完事了,多Slot情况下,就需要为每组数据分开创建缓冲区。在本例中是像下面这样创建了4个顶点数据缓冲区,以及对应的顶点缓冲描述符:
size_t szAlign = D3D12_DEFAULT_RESOURCE_PLACEMENT_ALIGNMENT;
// 计算所有的缓冲大小
size_t szPositions = g_stMeshData.m_arPositions.GetCount() * sizeof( g_stMeshData.m_arPositions[0] );
size_t szNormals = g_stMeshData.m_arNormals.GetCount() * sizeof( g_stMeshData.m_arNormals[0] );
size_t szTexCoords = g_stMeshData.m_arTexCoords.GetCount() * sizeof( g_stMeshData.m_arTexCoords[0] );
size_t szBoneIndices = g_stMeshData.m_arBoneIndices.GetCount() * sizeof( g_stMeshData.m_arBoneIndices[0] );
size_t szIndices = g_stMeshData.m_arIndices.GetCount() * sizeof( g_stMeshData.m_arIndices[0] );
// 需要的缓冲大小+64k-1 使得刚好是64k边界大小时,可以多分配64k出来,防止CreatePlacedResource报错
size_t szVBBuffer = GRS_UPPER( szPositions, szAlign )
+ GRS_UPPER( szNormals, szAlign )
+ GRS_UPPER( szTexCoords, szAlign )
+ GRS_UPPER( szBoneIndices, szAlign )
+ szAlign - 1;
D3D12_HEAP_DESC stUploadHeapDesc = { };
// 上传堆类型就是普通的缓冲,可以摆放任意数据
stUploadHeapDesc.Flags = D3D12_HEAP_FLAG_ALLOW_ONLY_BUFFERS;
// 实际数据大小的5*64K边界对齐大小,因为有5个Buffer
stUploadHeapDesc.SizeInBytes = GRS_UPPER( szVBBuffer, szAlign );
// 注意上传堆肯定是Buffer类型,可以不指定对齐方式,其默认是64k边界对齐
stUploadHeapDesc.Alignment = 0;
stUploadHeapDesc.Properties = stUploadHeapProps;
// 创建顶点数据的上传堆
GRS_THROW_IF_FAILED( pID3D12Device4->CreateHeap( &stUploadHeapDesc, IID_PPV_ARGS( &pIUploadHeapModel ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIUploadHeapModel );
size_t szOffset = 0;
BYTE* pData = nullptr;
// Positions Upload Buffer
stBufferResSesc.Width = GRS_UPPER( szPositions, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIUploadHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_GENERIC_READ
, nullptr
, IID_PPV_ARGS( &pIVBPositionsUp ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBPositionsUp );
GRS_THROW_IF_FAILED( pIVBPositionsUp->Map( 0, nullptr, reinterpret_cast<void**>( &pData ) ) );
// 第一次 Copy!CPU Memory -> Share Memory 实质上就是在内存中倒腾,只是物理地址不同
// 估计会有那么一天,这个Copy动作可能被简单替换为CPU物理地址映射的变更,传说Vulkan中已经大致是这样了
memcpy( pData, g_stMeshData.m_arPositions.GetData(), szPositions );
pIVBPositionsUp->Unmap( 0, nullptr );
pData = nullptr;
// Normals Upload Buffer
szOffset += stBufferResSesc.Width;
stBufferResSesc.Width = GRS_UPPER( szNormals, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIUploadHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_GENERIC_READ
, nullptr
, IID_PPV_ARGS( &pIVBNormalsUp ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBNormalsUp );
GRS_THROW_IF_FAILED( pIVBNormalsUp->Map( 0, nullptr, reinterpret_cast<void**>( &pData ) ) );
// 第一次 Copy!CPU Memory -> Share Memory 实质上就是在内存中倒腾,只是物理地址不同
// 估计会有那么一天,这个Copy动作可能被简单替换为CPU物理地址映射的变更,传说Vulkan中已经大致是这样了
memcpy( pData, g_stMeshData.m_arNormals.GetData(), szNormals );
pIVBNormalsUp->Unmap( 0, nullptr );
pData = nullptr;
// TexCoords Upload Buffer
szOffset += stBufferResSesc.Width;
stBufferResSesc.Width = GRS_UPPER( szTexCoords, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIUploadHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_GENERIC_READ
, nullptr
, IID_PPV_ARGS( &pIVBTexCoordsUp ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBTexCoordsUp );
GRS_THROW_IF_FAILED( pIVBTexCoordsUp->Map( 0, nullptr, reinterpret_cast<void**>( &pData ) ) );
// 第一次 Copy!CPU Memory -> Share Memory 实质上就是在内存中倒腾,只是物理地址不同
// 估计会有那么一天,这个Copy动作可能被简单替换为CPU物理地址映射的变更,传说Vulkan中已经大致是这样了
memcpy( pData, g_stMeshData.m_arTexCoords.GetData(), szTexCoords );
pIVBTexCoordsUp->Unmap( 0, nullptr );
pData = nullptr;
// Bone Indices Upload Buffer
szOffset += stBufferResSesc.Width;
stBufferResSesc.Width = GRS_UPPER( szBoneIndices, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIUploadHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_GENERIC_READ
, nullptr
, IID_PPV_ARGS( &pIVBBoneIndicesUp ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBBoneIndicesUp );
GRS_THROW_IF_FAILED( pIVBBoneIndicesUp->Map( 0, nullptr, reinterpret_cast<void**>( &pData ) ) );
// 第一次 Copy!CPU Memory -> Share Memory 实质上就是在内存中倒腾,只是物理地址不同
// 估计会有那么一天,这个Copy动作可能被简单替换为CPU物理地址映射的变更,传说Vulkan中已经大致是这样了
memcpy( pData, g_stMeshData.m_arBoneIndices.GetData(), szBoneIndices );
pIVBBoneIndicesUp->Unmap( 0, nullptr );
pData = nullptr;
// 创建默认堆(显存中的堆)
D3D12_HEAP_DESC stDefaultHeapDesc = {};
// 大小跟上传堆一样
stDefaultHeapDesc.SizeInBytes = stUploadHeapDesc.SizeInBytes;
stDefaultHeapDesc.Flags = D3D12_HEAP_FLAG_ALLOW_ONLY_BUFFERS;
// 指定堆的对齐方式,这里使用了默认的64K边界对齐,因为这里实际放的是顶点数据
stDefaultHeapDesc.Alignment = szAlign;
stDefaultHeapDesc.Properties = stDefautHeapProps;
// Vertex Data Default Heap
GRS_THROW_IF_FAILED( pID3D12Device4->CreateHeap( &stDefaultHeapDesc, IID_PPV_ARGS( &pIDefaultHeapModel ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIDefaultHeapModel );
// Positions Default Buffer
szOffset = 0;
stBufferResSesc.Width = GRS_UPPER( szPositions, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIDefaultHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_COPY_DEST
, nullptr
, IID_PPV_ARGS( &pIVBPositions ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBPositions );
// 第二次Copy!独显的时候:Share Memory -> Video Memory
// 这里只是记录个复制命令,之后会在Copy Engine上Excute
pICopyCMDList->CopyBufferRegion( pIVBPositions.Get(), 0, pIVBPositionsUp.Get(), 0, szPositions );
// 然后加入个资源屏障,同步! 并确认复制操作完成
stResStateTransBarrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
stResStateTransBarrier.Transition.StateAfter = D3D12_RESOURCE_STATE_COMMON;
stResStateTransBarrier.Transition.pResource = pIVBPositions.Get();
pICopyCMDList->ResourceBarrier( 1, &stResStateTransBarrier );
// Normals Default Buffer
szOffset += stBufferResSesc.Width;
stBufferResSesc.Width = GRS_UPPER( szNormals, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIDefaultHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_COPY_DEST
, nullptr
, IID_PPV_ARGS( &pIVBNormals ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBNormals );
// 第二次Copy!独显的时候:Share Memory -> Video Memory
// 这里只是记录个复制命令,之后会在Copy Engine上Excute
pICopyCMDList->CopyBufferRegion( pIVBNormals.Get(), 0, pIVBNormalsUp.Get(), 0, szNormals );
// 然后加入个资源屏障,同步! 并确认复制操作完成
stResStateTransBarrier.Transition.pResource = pIVBNormals.Get();
pICopyCMDList->ResourceBarrier( 1, &stResStateTransBarrier );
// TexCoords Default Buffer
szOffset += stBufferResSesc.Width;
stBufferResSesc.Width = GRS_UPPER( szTexCoords, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIDefaultHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_COPY_DEST
, nullptr
, IID_PPV_ARGS( &pIVBTexCoords ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBTexCoords );
// 第二次Copy!独显的时候:Share Memory -> Video Memory
// 这里只是记录个复制命令,之后会在Copy Engine上Excute
pICopyCMDList->CopyBufferRegion( pIVBTexCoords.Get(), 0, pIVBTexCoordsUp.Get(), 0, szTexCoords );
// 然后加入个资源屏障,同步! 并确认复制操作完成
stResStateTransBarrier.Transition.pResource = pIVBTexCoords.Get();
pICopyCMDList->ResourceBarrier( 1, &stResStateTransBarrier );
// Bone Indices Default Buffer
szOffset += stBufferResSesc.Width;
stBufferResSesc.Width = GRS_UPPER( szBoneIndices, szAlign );
GRS_THROW_IF_FAILED( pID3D12Device4->CreatePlacedResource(
pIDefaultHeapModel.Get()
, szOffset
, &stBufferResSesc
, D3D12_RESOURCE_STATE_COPY_DEST
, nullptr
, IID_PPV_ARGS( &pIVBBoneIndices ) ) );
GRS_SET_D3D12_DEBUGNAME_COMPTR( pIVBBoneIndices );
// 第二次Copy!独显的时候:Share Memory -> Video Memory
// 这里只是记录个复制命令,之后会在Copy Engine上Excute
pICopyCMDList->CopyBufferRegion( pIVBBoneIndices.Get(), 0, pIVBBoneIndicesUp.Get(), 0, szBoneIndices );
// 然后加入个资源屏障,同步! 并确认复制操作完成
stResStateTransBarrier.Transition.pResource = pIVBBoneIndices.Get();
pICopyCMDList->ResourceBarrier( 1, &stResStateTransBarrier );
// Positions Buffer View
staVBV[0].BufferLocation = pIVBPositions->GetGPUVirtualAddress();
staVBV[0].SizeInBytes = (UINT) szPositions;
staVBV[0].StrideInBytes = sizeof( g_stMeshData.m_arPositions[0] );
// Normals Buffer View
staVBV[1].BufferLocation = pIVBNormals->GetGPUVirtualAddress();
staVBV[1].SizeInBytes = (UINT) szNormals;
staVBV[1].StrideInBytes = sizeof( g_stMeshData.m_arNormals[0] );
// TexCoords Buffer View
staVBV[2].BufferLocation = pIVBTexCoords->GetGPUVirtualAddress();
staVBV[2].SizeInBytes = (UINT) szTexCoords;
staVBV[2].StrideInBytes = sizeof( g_stMeshData.m_arTexCoords[0] );
// BoneIndices Buffer View
staVBV[3].BufferLocation = pIVBBoneIndices->GetGPUVirtualAddress();
staVBV[3].SizeInBytes = (UINT) szBoneIndices;
staVBV[3].StrideInBytes = sizeof( g_stMeshData.m_arBoneIndices[0] );
代码中,首先创建了一个自定义上传堆(独立上传堆),接着用“定位”(CreatePlacedResource)方式创建了资源缓冲(共享内存中),紧接着将每组顶点数据从内存中Copy(memcpy)到上传堆中,又接着创建了对应的自定义默认堆(独立默认堆),同样在默认堆上创建对应的默认堆上的资源缓冲(显存中),然后使用第二次Copy(CopyBufferRegion)将数据从上传堆中复制到默认堆中。最后填充了资源缓冲视图数组(D3D12_VERTEX_BUFFER_VIEW staVBV[g_ncSlotCnt] = {};),即创建了资源缓冲视图。希望这个过程对你来说已经没有什么阅读和理解障碍了,这就是之前教程中已经反复讲过和用的代码了,只是因为没有封装,所以代码看起来有点长而已。
12.4、多Slot渲染
上面的步骤都结束后,所有的关于多Slot传输数据的准备工作就算完成了,剩下的就是渲染了。之前的教程示例代码中,因为模型的简单性,实质上只需要渲染一个网格的一个实例即可,而现在就需要考虑渲染多个缓冲视图组成的连续网格数据的一个实例(关于多实例渲染后续会讲到,敬请期待!),这时需要一次渲染中设置多个缓冲视图并做循环渲染:
// Multi Input Slot 第一种方式 IASetVertexBuffers
pIMainCMDList->IASetVertexBuffers( 0, g_ncSlotCnt, staVBV );
// 或者
// Multi Input Slot 第二种方式 IASetVertexBuffers
for (UINT i = 0; i < g_ncSlotCnt; i++)
{
pIMainCMDList->IASetVertexBuffers(i, 1, &staVBV[i]);
}
pIMainCMDList->IASetIndexBuffer( &stIBV );
// ......
// Draw Call
for ( UINT i = 0; i < g_stMeshData.m_arSubMeshInfo.GetCount(); i++ )
{
UINT nHeapIndex = 0;
D3D12_GPU_DESCRIPTOR_HANDLE stHSRV = stGPUSRVHandle;
if ( g_stMeshData.m_mapTextureIndex2HeapIndex.Lookup( g_stMeshData.m_arSubMeshInfo[i].m_nMaterialIndex, nHeapIndex ) )
{
stHSRV.ptr += ((size_t) nHeapIndex * nCBVSRVDescriptorSize );
}
pIMainCMDList->SetGraphicsRootDescriptorTable( 1, stHSRV );
pIMainCMDList->DrawIndexedInstanced( g_stMeshData.m_arSubMeshInfo[i].m_nNumIndices
, 1
, g_stMeshData.m_arSubMeshInfo[i].m_nBaseIndex
, g_stMeshData.m_arSubMeshInfo[i].m_nBaseVertex
, 0 );
}
代码中已经描述清楚了,我们有两种方法来设置这多个Slot对应的缓冲视图,然后循环来调用DrawCall。在祖传的D3D11之前的版本中,这样的代码就会引起极大的性能问题,就是之前教程中讲过的“同步DrawCall”问题,而现在D3D12中这已经不是什么问题了,希望你也已经彻底明白了D3D12中“异步”的全部含义了,之前的教程中也已经详细深入的介绍过相关内容,这里只是帮你回忆一下。
最后重点强调一下多Slot加载顶点数据,也是多实例渲染的一个基本功能,所以请务必牢固掌握这一技巧!
13、动画动作状态机
至此主要的动画渲染过程就介绍完毕了,最后探讨一个稍微高级的点的话题,就是相对于“3D骨骼动画渲染”的“上层建筑”的内容。在本章之前的介绍中,已经说过,对于一个aiScene对象来说,其aiAnimation成员变量其实是一个数组,而我们的过程看上去好像只用一个元素,就可以渲染完一组动画了,这是什么意思呢?
其实aiAnimation数组中,每一个成员就代表一个“完整的动作”,比如,对于人体模型来说,走动这个动作的完整过程(也就是“左-右-左”的一个完整周期,剩下的重复播放就可以了),就存储为一个aiAnimation数组元素,接着可能还有“跑动”、“站立”、“攻击”、“大招”等等完整的动作,每一个都作为一个数组元素。在本章示例配送的hero.x模型中,就有大概6个动作,大家可以运行示例旋转加载该模型,然后按空格键切换查看。另一个模型lxq.x中则只有一个动作。
那么接着你可能就会简单的以为:哇!太酷了!这样按数组索引就可以控制播放不同的动作,人物的动画状态就可以控制了!
但是请等等,不要太过兴奋,思考这样一个问题,所有的动作间是不是真的可以随便切换?既然从头到尾,其实我们说的整个动画渲染的过程,包括网格、纹理等等都不过是一个“模拟”的过程,模拟的目的无非就是要“逼真”。那么真实的分解“动作”之间是否真的可以随便切换呢?现实中这是不可能的,比如,一个跑动中的人,不可能是轻易就可以放出大招的,往往需要一些中间动作,比如:从跑动先停下来变成站立预备动作,然后放出致命的大招攻击敌人。
另外从控制的角度,其实就是比如我们用鼠标键盘控制角色的动作时,一样也要考虑动作过渡的过程,比如人物角色正在匍匐前进,这是要进入跑动射击状态,那么就需要首先经过从匍匐姿势到站立姿势再到跑动并混合射击的过程,那么同样如何正确的在程序中表达这一控制过程呢?进一步的,思考如果某个角色是AI控制的NPC角色时?动作状态又如何优雅自然的控制过渡呢?
最终像这样的“动作切换”过程,也就是动作控制的过程,就可以用一个“动作状态机”(FSM)来描述,比如刚才描述的情况就可以像下面这样来描述:
这个图看上去有点复杂,并且因为绘图工具的问题,只好用双向箭头来表示两条不同向的边,另外边上的条件也没有描述。先不用纠结这些细节,来看一下图中如果我们要从跑步的动作到攻击的动作,那就必须要经过站立的动作作为过渡,即停下来开始攻击,这样最终动作的过渡无非就是计算一条从某个当前状态至目标状态的一条路径。
而图中边上的条件其实就是“控制指令”(即状态图上的输入,但不是说输入就非得来自于鼠标或键盘等输入设备),它可以来自于鼠标键盘手柄等输入设备,也可以来自于AI。
最终这样的状态图就可以完美的解决动画动作的过渡,并天然的抽象了对动作过渡的控制,形成一个“动画控制系统”,因为状态转换的指令可以来自输入设备,也可以来自AI的控制指令,还可以是从网络传输过来的别的玩家的指令(对网络传输来说,只传输动作指令和时间点基本上就可以同步多玩家之间的动画了,而不用传输“动画调试般”,这是巨大的优势!)。
14、后记
最后需要提到的是,在本章示例中,其实我们并没有考虑诸如动作之间的插值过渡、动作混合、碰撞反馈等等更高级一点的话题,当然这些都是现代完整的动画系统中必不可少的基础功能。但本章的基本目的是让大家对骨骼动画有个基础性的了解和掌握,有了基础剩下的内容我相信各位再通过进一步学习,并掌握也就不是什么难事了。
本章内容历时将近一个多月才完成,而代码早就提交到了Github,内容也接近3万字了,期间实在是因为最近实在是太忙了,有很多事情,也就是工作、生活、学习等等方面的事情都需要去处理,因此过程有点拖沓了,请各位谅解!并恳请各位看过后不忘点赞、收藏、评论三连支持一下,算是鼓励下我,继续把该系列教程完成下去。
后续,将进入现代高级光照渲染话题的PBR渲染相关内容(也会有延迟渲染等),敬请期待!
暂无评论内容