应用场景
主流的GPU蒙皮方案需要在CPU中计算骨骼变换,然后传输到GPU中进行蒙皮。在角色数量较少的情况下,该方案性能良好。但是当场景中存在大量需要蒙皮、而且动画不同步的角色时,性能下降明显。主要的性能瓶颈包括:
计算动画状态(状态机,采样,混合,物理,IK)
计算骨骼变换(递归进行大量矩阵运算)
传输骨骼数据到GPU
大量的draw call和面片数
GPU Skinning技术
GPU Skinning(GPU蒙皮)是一种在图形处理单元(GPU)上执行骨骼动画计算的技术。与传统的CPU Skinning(在中央处理单元上执行蒙皮计算)相比,GPU Skinning可以显著提高动画性能,特别是在处理大量角色或复杂动画时。以下是关于GPU Skinning的详细介绍。
基本概念
骨骼动画
骨骼动画是一种常见的动画技术,通过使用一组骨骼(或关节)来驱动网格的变形。每个骨骼都有一个变换矩阵,用于描述其位置、旋转和缩放。网格的每个顶点都与一个或多个骨骼关联,并根据这些骨骼的变换进行插值计算。
蒙皮
蒙皮是将骨骼动画应用到网格上的过程。每个顶点根据其关联的骨骼和权重进行变形。传统的CPU Skinning在CPU上执行这些计算,然后将变形后的顶点数据传递给GPU进行渲染。
GPU Skinning的优势
- 性能提升:GPU具有强大的并行计算能力,可以同时处理大量顶点变形计算,从而显著提高动画性能。
- 减少CPU负载:将蒙皮计算从CPU转移到GPU,可以释放CPU资源,用于处理其他任务,如物理计算、AI逻辑等。
- 减少数据传输:在CPU Skinning中,变形后的顶点数据需要从CPU传输到GPU,而在GPU Skinning中,顶点数据直接在GPU上进行变形,减少了数据传输的开销。
实现原理
顶点着色器
GPU Skinning通常在顶点着色器中实现。顶点着色器是一个可编程的GPU阶段,用于处理每个顶点的属性。通过在顶点着色器中执行蒙皮计算,可以利用GPU的并行计算能力。
骨骼矩阵
骨骼的变换矩阵通常作为常量缓冲区(Constant Buffer)或纹理传递给顶点着色器。每个顶点根据其关联的骨骼矩阵和权重进行变形计算。
代码示例
以下是一个简单的HLSL(High-Level Shading Language)顶点着色器示例,展示了如何在GPU上实现蒙皮计算:
cbuffer BoneMatrices : register(b0)
{
matrix BoneMatrixArray[100]; // 假设最多有100个骨骼
};
struct VS_INPUT
{
float4 Position : POSITION;
float4 Weights : BLENDWEIGHT;
uint4 BoneIndices : BLENDINDICES;
};
struct VS_OUTPUT
{
float4 Position : SV_POSITION;
};
VS_OUTPUT main(VS_INPUT input)
{
VS_OUTPUT output;
// 初始化变形后的顶点位置
float4 skinnedPosition = float4(0, 0, 0, 0);
// 计算顶点的变形位置
for (int i = 0; i < 4; ++i)
{
uint boneIndex = input.BoneIndices[i];
float weight = input.Weights[i];
skinnedPosition += mul(input.Position, BoneMatrixArray[boneIndex]) * weight;
}
output.Position = skinnedPosition;
return output;
}
实践中的注意事项
- 骨骼数量限制:由于常量缓冲区的大小限制,顶点着色器中可以处理的骨骼数量是有限的。可以通过分批处理或使用纹理存储骨骼矩阵来解决这个问题。
- 精度问题:在GPU上进行浮点运算时,可能会遇到精度问题。需要确保骨骼矩阵和权重的精度足够高,以避免动画失真。
- 性能优化:可以通过减少顶点关联的骨骼数量、优化骨骼矩阵的存储方式等方法,进一步提高GPU Skinning的性能。
总结
GPU Skinning是一种高效的骨骼动画技术,通过在GPU上执行蒙皮计算,可以显著提高动画性能,减少CPU负载和数据传输开销。通过合理设计和优化,可以在实际项目中充分发挥GPU Skinning的优势。
主要特性
实现GPU Skinning+Instancing,运行时CPU不进行骨骼计算,只做动画状态更新
支持两个动画的混合和过渡
每个实例的坐标变换和动画状态可以独立控制
实现千人同屏,4档机达到20帧,2档机稳定60帧(仅Unity,详细的场景数据请见后文总结部分)
支持简单的武器挂载,每个武器的坐标变换和附着状态可以独立控制(仅Unity)
支持对偶四元数运算(仅Unity)
支持动态阴影、网格和材质LOD、PBR材质(仅Unity)
GPU Skinning技术CPU端数据传输逻辑
在实现GPU Skinning技术时,CPU端的数据传输逻辑是关键的一环。它涉及将骨骼变换矩阵和网格数据传递到GPU,以便在顶点着色器中进行蒙皮计算。以下是详细的步骤和注意事项。
数据准备
1. 骨骼变换矩阵
每个骨骼都有一个变换矩阵,用于描述其位置、旋转和缩放。通常,这些矩阵在CPU端计算并更新,然后传递给GPU。
2. 网格数据
网格数据包括顶点位置、骨骼权重和骨骼索引。每个顶点可能与多个骨骼关联,并且每个关联都有一个权重值。
数据传输步骤
1. 计算骨骼变换矩阵
在CPU端,根据动画帧和骨骼层次结构计算每个骨骼的变换矩阵。这些矩阵通常存储在一个数组中。
Matrix4x4[] boneMatrices = new Matrix4x4[boneCount];
for (int i = 0; i < boneCount; i++)
{
boneMatrices[i] = CalculateBoneMatrix(i);
}
2. 创建和更新常量缓冲区
将骨骼变换矩阵传递给GPU,通常使用常量缓冲区(Constant Buffer)。在DirectX或OpenGL中,可以创建一个常量缓冲区并在每帧更新它。
// DirectX 11 示例
ID3D11Buffer* boneBuffer = nullptr;
D3D11_BUFFER_DESC bufferDesc = {};
bufferDesc.Usage = D3D11_USAGE_DYNAMIC;
bufferDesc.ByteWidth = sizeof(Matrix4x4) * boneCount;
bufferDesc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
bufferDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
device->CreateBuffer(&bufferDesc, nullptr, &boneBuffer);
// 每帧更新骨骼矩阵
D3D11_MAPPED_SUBRESOURCE mappedResource;
context->Map(boneBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedResource);
memcpy(mappedResource.pData, boneMatrices, sizeof(Matrix4x4) * boneCount);
context->Unmap(boneBuffer, 0);
// 将常量缓冲区绑定到顶点着色器
context->VSSetConstantBuffers(0, 1, &boneBuffer);
3. 创建和更新顶点缓冲区
网格数据(包括顶点位置、骨骼权重和骨骼索引)需要传递给GPU。可以创建一个顶点缓冲区并在需要时更新它。
// 顶点结构
struct Vertex
{
Vector3 Position;
Vector4 Weights;
uint4 BoneIndices;
};
// 创建顶点缓冲区
ID3D11Buffer* vertexBuffer = nullptr;
D3D11_BUFFER_DESC vertexBufferDesc = {};
vertexBufferDesc.Usage = D3D11_USAGE_DYNAMIC;
vertexBufferDesc.ByteWidth = sizeof(Vertex) * vertexCount;
vertexBufferDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vertexBufferDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
device->CreateBuffer(&vertexBufferDesc, nullptr, &vertexBuffer);
// 每帧更新顶点数据
D3D11_MAPPED_SUBRESOURCE vertexMappedResource;
context->Map(vertexBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &vertexMappedResource);
memcpy(vertexMappedResource.pData, vertices, sizeof(Vertex) * vertexCount);
context->Unmap(vertexBuffer, 0);
// 将顶点缓冲区绑定到输入装配器
UINT stride = sizeof(Vertex);
UINT offset = 0;
context->IASetVertexBuffers(0, 1, &vertexBuffer, &stride, &offset);
注意事项
- 性能优化:尽量减少数据传输的频率和数据量。可以通过批量更新数据、使用更高效的数据结构等方法优化性能。
- 内存管理:确保在适当的时候释放GPU资源,避免内存泄漏。
- 同步问题:在多线程环境中,确保数据更新和传输的同步,避免竞争条件和数据不一致。
- 平台兼容性:不同图形API(如DirectX、OpenGL、Vulkan)在数据传输和资源管理上有不同的实现细节,需要根据具体平台进行调整。
总结
如何减少draw call?
使用GPU instancing。借用引擎的instancing特性,相同网格和材质只需要消耗一次draw batch,即可绘制大量实例。运行时只需要花费更新instance transform的开销。
如何避免运行时计算骨骼变换矩阵?
在编译期或者设计期,可以把所有可能用到的动画播放一遍,提取每一个采样点的模型坐标骨骼变换,储存到一张纹理中。运行时,在GPU端采样这些纹理,就可以还原出骨骼变换,直接用于蒙皮
如何更新动画?
基于上一条,因为骨骼变换已经在GPU端,更新骨骼变换实际上只需要更新动画索引。CPU中计算好当前动画播放的id和进度,更新到GPU,GPU根据这个信息生成纹理索引UV,就可以获得新的蒙皮矩阵。
如何在instancing的同时进行skinning?
在引擎本身的instancing管线之前,在vertex shader中对每一个顶点进行蒙皮变换即可。需要保证蒙皮变换先于所有其他变换进行(其他变换指的是instance, world, view, projection变换)。由于每一个实例的动画状态不相同,需要使用instancing的数据通道来传输动画状态数据(该通道取决于引擎本身的实现,底层通常是uniform buffer)
如何加速动画状态的计算?
这个问题事实上没有很好的解决办法。动画状态更新之所以开销大,是因为动画逻辑复杂。但是在我们的目标场景中,一般不必支持所有的动画功能。比如IK,加法混合等,在大规模简单人群中不一定是必须的。本文只实现了动画的线性过渡,以及硬编码的动画状态机。在实际项目中,可以根据需要采用更复杂的实现,或者直接从引擎自带的动画状态机中获取状态,如此也可以兼容所有的动画功能。
难点
移动设备中,大量进行纹理采样会遇到内存瓶颈,GPU的计算能力也受到限制
性能问题
GPU Skinning技术在提高动画性能方面具有显著优势,但在实际应用中也可能面临一些性能问题。以下是一些常见的性能问题及其可能的解决方案:
1. 骨骼数量限制
问题描述
GPU的常量缓冲区(Constant Buffer)或统一缓冲区(Uniform Buffer)有大小限制,这限制了可以同时处理的骨骼数量。如果骨骼数量超过了这个限制,可能需要分批处理,增加了复杂性和开销。
解决方案
- 分批处理:将骨骼分成多个批次,每个批次在一个渲染调用中处理。
- 使用纹理存储骨骼矩阵:将骨骼矩阵存储在纹理中,通过纹理采样获取骨骼矩阵,突破常量缓冲区的限制。
2. 数据传输开销
问题描述
在每帧更新骨骼矩阵并传输到GPU时,可能会产生较大的数据传输开销,特别是在骨骼数量较多的情况下。
解决方案
- 减少数据传输频率:仅在骨骼矩阵发生变化时更新数据,而不是每帧都更新。
- 批量更新:将所有需要更新的数据一次性传输到GPU,减少传输调用次数。
3. 着色器复杂度
问题描述
在顶点着色器中进行骨骼变换计算会增加着色器的复杂度,可能导致着色器执行时间增加,特别是在顶点数量较多的情况下。
解决方案
- 优化着色器代码:简化和优化顶点着色器中的计算,减少不必要的运算。
- 使用计算着色器:在某些情况下,可以使用计算着色器(Compute Shader)进行预处理,将结果存储在缓冲区中,然后在顶点着色器中直接使用预处理结果。
4. 内存带宽限制
问题描述
GPU的内存带宽限制可能会影响数据传输和处理速度,特别是在处理大量顶点和骨骼数据时。
解决方案
- 压缩数据:使用更紧凑的数据格式,减少数据量。例如,可以使用半精度浮点数(16位浮点数)代替全精度浮点数(32位浮点数)。
- 优化内存访问模式:确保数据在内存中的布局是连续的,减少内存访问的开销。
5. 动态顶点数据更新
问题描述
在某些情况下,顶点数据(如位置、法线等)需要在每帧动态更新,这会增加CPU和GPU之间的数据传输开销。
解决方案
- 使用动态顶点缓冲区:在需要更新顶点数据时,使用动态顶点缓冲区(Dynamic Vertex Buffer)进行更新。
- 减少顶点数据更新频率:仅在必要时更新顶点数据,避免不必要的更新。
6. 资源管理和同步问题
问题描述
在多线程环境中,资源管理和数据同步可能会引入额外的开销和复杂性,导致性能下降。
解决方案
- 合理的资源管理策略:使用合适的资源管理策略,确保资源的创建、更新和销毁在适当的时机进行。
- 同步机制:使用合适的同步机制,确保数据在多个线程之间的一致性,避免竞争条件。
总结
尽管GPU Skinning技术在提高动画性能方面具有显著优势,但在实际应用中仍然需要注意和解决一些性能问题。通过合理的优化策略和技术手段,可以最大限度地发挥GPU Skinning的优势,提高整体渲染性能。
数据预处理
本方案的核心思想是直接在GPU中获取预计算好的骨骼变换。因此要解决的问题有两个:如何在CPU端储存数据,如何在GPU端访问数据。
根据蒙皮的原理,骨骼mesh的每一个顶点会绑定若干个骨骼(通常是4个,移动端常用2个),每个骨骼带有一个权重。运行时,动画系统会不断更新skeleton中所有骨骼的变换矩阵。GPU在绘制每一个顶点时,就根据顶点绑定的骨骼,取出每个骨骼的矩阵变换,然后使用骨骼权重求出这几个矩阵的加权和,获得最终的蒙皮矩阵。
顶点v在第f帧时的蒙皮变换可以表示为(4个绑定骨骼):
其中,M是骨骼的变换矩阵,w是骨骼权重,b是该顶点绑定的骨骼ID数组。因此问题的关键就是如何获取b、w这两个数组,以及如何根据骨骼ID和帧ID来获取M。
由于绑定骨骼b和绑定权重w是和顶点一一对应的,而且不会随时间改变。这两个数据可以直接储存在顶点的vertex属性里。常规的vertex属性通道中,比较适合储存线性数据的是tangent和UV通道。简单起见,本文选择将其储存在tangent通道。
至于变换矩阵M,由于它取决于骨骼ID和帧ID这两个参数,很自然可以想到使用一个二维数组来储存它。在渲染引擎里,支持最好的二维数组就是纹理。在这里不妨规定一个纹理的横坐标(U)表示骨骼索引,纵坐标(V)表示帧索引。同时,因为一个像素最多储存4个值,而一个矩阵有16个元素,所以总共需要4张纹理,每张纹理保存矩阵的一行(另一个方案是在1张纹理中连续储存矩阵的4行。但是实验发现性能上并没有可见的优势,所以选择了代码实现上更简明的做法)。
例如,一个58骨骼,总共160帧(10个动画)的动画纹理如下(已经过折叠和下采样)
对每一个需要instancing的mesh,提取其携带的骨骼绑定信息,变换到UV Space,然后储存在顶点的tangent通道。考虑移动平台的性能,这里只使用两个骨骼。另一个需要注意的问题是,上文的M一般需要预乘绑定姿势的逆矩阵。本文为了兼容后面性能优化部分提到的SQT变换、对偶四元数变换,在这里直接对每一个顶点做绑定姿势的逆变换,从而M只需要包含骨骼变换。修改后的mesh会缓存到内存中用于稍后渲染
BakeAnimation:逐个播放所有可能需要用到的动画,获取每一帧的所有骨骼变换,按次序保存在4张纹理中。每个动画之间使用了padding来避免采样bleeding。为了节省空间,对于超出纹理大小的帧进行折叠处理。同时可以指定一个采样率,在保证动画效果的情况下采样更少的帧数来节省空间。纹理采用的是RGBA16 Half类型的未压缩格式,不使用mipmap。最终产生的纹理会保存在Resources目录下,可以在运行时热加载
678

被折叠的 条评论
为什么被折叠?



