UE VAT
UE VAT
最近使用了一下UE中的顶点动画贴图,这篇文章做一个记录。
这个功能在可以显著的提高CPU的性能,因为动画计算和蒙皮计算都在GPU上进行,同时也减少了CPU与GPU的数据传输。同时也减小了内存的压力。
该功能适用于做复杂的动画效果,如流体动力模拟,破碎效果。也适用于做人群,在虚幻的官方示例CitySample中,NPC就是使用VAT来做渲染。
动画烘焙
使用UE5自带的角色制作一个简单的顶点动画并播放,来讲解顶点动画的工作流。
准备
新建一个第三人称模板项目,打开插件AnimToTexture

根据引擎版本下载顶点动画导出工具:EUW_VAT_Utils.uasset
,该工具主要功能是帮助创建和配置AnimToTextureDataAsset
。下载地址:AnimToTextureHelpers

如果你使用的引擎版本都不能打开,可以使用工具指定版本打开该工具,查看核心的方法,然后在自己的引擎中重写一下导出方法即可。其中核心方法有:
- CreateTexture:
- CreateStaticMesh:
- BakeData:
可以自行创建AnimToTextureDataAsset
并配置,最后点击Run Animation To Texture
即可。

创建配置
右键运行工具:

工具详细图:

该工具从上到下一次点击就可以了,下面一步一步讲解:
- 选中
/All/Game/Characters/Mannequins/Meshes/SKM_Quinn
骨骼网格 - 点击
Create Static Mesh from Slected
创建静态网格SM_SKM_Quinn
- 点击
Create Textures
创建空的动画贴图 - 点击
Create DataAsset
创建空的AnimToTextureDataAsset
配置表 - 设置需要烘焙的动画切片
- 点击
Set Data Params
将工具中的设置复制到AnimToTextureDataAsset
配置表 - 点击
BAKE DATA
将动画信息烘焙到贴图中
这就烘焙完成,如果点击烘焙没有反应,可以查看一下DA_SKM_Quinn
配置表中是否有缺失,完整配置如下:

报错:Invalid Static Mesh UV Channel: 1 Already used by LightMap
只需要在Static Mesh
的设置中关闭以下选项即可:

修改材质
因为是GPU动画蒙皮,所以需要把动画贴图传给shader。AnimToTexture
插件提供了简单的接口,我们只需要修改材质即可。
首先打开SM_SKM_Quinn
查看材质:

分别找到这两材质实例的材质基类,它们的基类都为M_Mannequin
,所以只需修改这个材质即可。
打开该材质,首先使用MakeMaterialAttributes
替换掉M_Mannequin

将Use Material Attributes
选项打开,将输出点改为Material Attitudes

这里直接连接,材质就回到最开始的样式了。

添加以下节点即可:

这些节点可以在AnimToTexture
插件中找到示例,也就是文件:/All/EngineData/Plugins/AnimToTexture/Characters/Mannequin/Materials/BoneAnimation/M_Body_BoneAnimation
。
值得注意的是,需要检查一下Material Attribute Layers
节点中是否是使用的ML_BoneAnimation
,如下图:

启用动画贴图
在修改材质实例前,需要复制静态网格上的两个材质实例,因为这两个材质实例同时也被骨骼网格使用到的。将复制的两个材质设置到SM_SKM_Quinn
上。

打开这两个材质实例的开关Use Bone Animation Texture

这时可以再点击一次烘焙贴图,或者直接运行AnimToTextureDataAsset
配置表的烘焙命令。工具将自动把动画信息填入材质实例,配置如下:

修改材质这一步可以放在创建静态模型成功之后,这样就可以减少一次烘焙操作。
播放动画
AnimToTexture
插件中提供了一些播放的方式,把静态模型放在UInstancedStaticMeshComponent
来控制渲染。
创建一个简单Actor来讲解一下,下图是这个Actor的构造函数:

主要工作有3个:
- 初始化IMC的实例数量
- 从配置表获取需要播放动画数据
- 把动画数据传递给IMC
最终的效果:

插件中也有播放动画的例子:
- /All/EngineData/Plugins/AnimToTexture/Characters/Mannequin/BP_InstancerAutoPlayData
- /All/EngineData/Plugins/AnimToTexture/Characters/Mannequin/BP_InstancerFrameData
播放的实现代码在UAnimToTextureInstancePlaybackLibrary
文件中。
当然我们也可以不使用UInstancedStaticMeshComponent
作为载体,直接使用UStaticMeshComponent
也是可以的,实时修改材质实例中以下这些参数也可以,前提是需要打在UseDynaicParameters
。

原理
其实原理说起来很简单,就是把动画每一帧的姿势中的各个骨骼按一定的顺序存储在贴图中,贴图的每个像素就存储这一个骨骼的位置/旋转/缩放信息。播放动画时,在顶点动画中对动画贴图进行采样,就可以获得姿势,再做GPU蒙皮即可。
基础的原理可以看这篇文章:Rendering Instanced Crowds,这是基于OpenGL简单实现的动画贴图,具体实现可查看:Crowds Sample
下面简单介绍一下AnimToTexture
插件的实现原理:
动画烘培
烘焙的主要逻辑在UAnimToTextureBPLibrary::AnimationToTexture
,主要的步骤有以下几步:
同步静态模型和骨骼模型数据
将骨骼网格中的顶点,三角形,蒙皮权重数据同步给静态模型,因为有可能存在这些数据不不同的情况。具体方法如下:
void Update(const UStaticMesh* StaticMesh, const int32 StaticMeshLODIndex,
const USkeletalMesh* SkeletalMesh, const int32 SkeletalMeshLODIndex,
const int32 NumDrivers, const float Sigma=1.f);
参考姿势数据
这里的参考姿势也就是常说的绑定姿势。
TArray<FVector3f> BoneRefPositions;
TArray<FVector4f> BoneRefRotations;
TArray<FVector3f> BonePositions;
TArray<FVector4f> BoneRotations;
if (DataAsset->Mode == EAnimToTextureMode::Bone)
{
// Gets Ref Bone Position and Rotations.
DataAsset->NumBones = GetRefBonePositionsAndRotations(DataAsset->GetSkeletalMesh(),
BoneRefPositions, BoneRefRotations);
// Add RefPose
// Note: this is added in the first frame of the Bone Position and Rotation Textures
BonePositions.Append(BoneRefPositions);
BoneRotations.Append(BoneRefRotations);
}
这里获取的位置和旋转信息都是组件空间的信息,同时旋转信息并不是储存的四元数,是存的旋转轴和旋转角度,这有点费解为什么这样存,可以看到解压出来的旋转计算方式如下:
// Get Rotation
const FQuat4f Quat = (FQuat4f)Transform.GetRotation();
FVector3f Axis;
float Angle;
Quat.ToAxisAndAngle(Axis, Angle);
OutRotation = FVector4f(Axis, Angle);
转化动画数据
采样动画使用了一个临时USkeletalMeshComponent
组件作为载体获取骨骼姿势的信息。这里只介绍采取骨骼信息的方式。
首先设置需要采样的动画:
UAnimSequence* AnimSequence = AnimSequenceInfo.AnimSequence;
SkeletalMeshComponent->SetAnimation(AnimSequence);
再根据动画帧数,一帧一帧进行采样:
while (SampleIndex < AnimNumFrames)
{
const float Time = AnimStartTime + ((float)SampleIndex * SampleInterval);
SampleIndex++;
// Go To Time
SkeletalMeshComponent->SetPosition(Time);
// Update SkelMesh Animation.
SkeletalMeshComponent->TickAnimation(0.f, false /*bNeedsValidRootMotion*/);
SkeletalMeshComponent->RefreshBoneTransforms(nullptr /*TickFunction*/);
TArray<FVector3f> BoneFramePositions;
TArray<FVector4f> BoneFrameRotations;
GetBonePositionsAndRotations(SkeletalMeshComponent, BoneRefPositions,
BoneFramePositions, BoneFrameRotations);
BonePositions.Append(BoneFramePositions);
BoneRotations.Append(BoneFrameRotations);
}
这里存储骨骼的位置信息为当前骨骼位置减去参考姿势下骨骼的位置,也就是变换位移:
// Position Delta (from RefPose) ComponentSpace
BonePositions[BoneIndex] = BonePosition - BoneRefPositions[BoneIndex];
而旋转信息是直接使用的从 参考姿势空间 到 局部空间 的变换矩阵的旋转信息:
TArray<FMatrix44f> RefToLocals;
SkeletalMeshComponent->CacheRefToLocalMatrices(RefToLocals);
// Decompose Transformation (Relative to RefPose)
FVector3f BoneRelativePosition;
FVector4f BoneRelativeRotation;
const FMatrix RefToLocalMatrix(RefToLocals[BoneIndex]);
const FTransform RelativeTransform(RefToLocalMatrix);
DecomposeTransformation(RelativeTransform, BoneRelativePosition, BoneRelativeRotation);
BoneRotations[BoneIndex] = BoneRelativeRotation;
设置贴图大小
计算贴图大小如下:
OutRowsPerFrame = FMath::CeilToInt(NumBones / (float)MaxWidth);
OutWidth = FMath::CeilToInt(NumBones / (float)OutRowsPerFrame);
OutHeight = NumFrames * OutRowsPerFrame;
简单来说,当骨骼数量小于最大宽度时:
- Width:骨骼的数量
- Height:帧数
标准化数据
对骨骼位置和旋转信息进行标准化到[0,1]
。
位置的计算:
OutSizeBBox = MaxBBox - OutMinBBox;
// Compute Normalization Factor per-axis.
const FVector3f NormFactor = {
1.f / static_cast<float>(OutSizeBBox.X),
1.f / static_cast<float>(OutSizeBBox.Y),
1.f / static_cast<float>(OutSizeBBox.Z) };
OutNormalizedPositions[Index] = (Positions[Index] - OutMinBBox) * NormFactor;
简单的计算了所有位置数据的边界(最大值,最小值),然后使用当前值除以边界大小即可。
旋转值的计算:
const FVector4f Axis = Rotations[Index];
// Angle are returned in radians andthey go from [0-pi*2]
const float Angle = Rotations[Index].W;
OutNormalizedRotations[Index] = (Axis.GetSafeNormal() + FVector3f::OneVector)* 0.5f;
OutNormalizedRotations[Index].W = Angle / (PI * 2.f);
这里把四元素转化为旋转轴和旋转角来计算,使用的通用公式。转为这样在Shader中方便使用。
写入位置和旋转贴图
最终将数据写入贴图,贴图每个像素的分布如下:

这里提供了两个贴图格式:
- ETextureSourceFormat::TSF_BGRA8
- ETextureSourceFormat::TSF_RGBA16
权重贴图
再没指定插槽时,从骨骼网格中把骨骼权重信息复制到静态网格中,并权重骨骼降低到4根。
// Reduce BoneWeights to 4 Influences.
if (SocketIndex == INDEX_NONE)
{
// Project SkinWeights from SkeletalMesh to StaticMesh
TArray<VertexSkinWeightMax> StaticMeshSkinWeights;
Mapping.ProjectSkinWeights(StaticMeshSkinWeights);
// Reduce Weights to 4 highest influences.
ReduceSkinWeights(StaticMeshSkinWeights, SkinWeights);
}
具体写入贴图的方法是AnimToTexture_Private::WriteSkinWeightsToTexture
,计算骨骼索引和权重标准化:
// Normalize BoneIndices
const FVector4f BoneIndices(
(float)VertexSkinWeight.MeshBoneIndices[0] / float(NumBones),
(float)VertexSkinWeight.MeshBoneIndices[1] / float(NumBones),
(float)VertexSkinWeight.MeshBoneIndices[2] / float(NumBones),
(float)VertexSkinWeight.MeshBoneIndices[3] / float(NumBones));
// Normalize BoneWeights
const FVector4f BoneWeights(
(float)VertexSkinWeight.BoneWeights[0] / 255.f,
(float)VertexSkinWeight.BoneWeights[1] / 255.f,
(float)VertexSkinWeight.BoneWeights[2] / 255.f,
(float)VertexSkinWeight.BoneWeights[3] / 255.f);
两个数据对应到像素数组中:
// Write BoneIndex
typename TextureSettings::ColorType& Pixel = Pixels[VertexIndex];
VectorToColor<FVector4f, typename TextureSettings::ColorType>(BoneIndices, Pixel);
// Write BoneWeight
typename TextureSettings::ColorType& Pixel = Pixels[RowsPerFrame * Width + VertexIndex];
VectorToColor<FVector4f, typename TextureSettings::ColorType>(BoneWeights, Pixel);
动画播放
动画最终是应用在每个顶点的位移和法线上,核心的方法是BlendBonePositionAndNormal,最终输出的数据如下:

下面一步一步的介绍是如何播放动画。
传输数据
插件中使用UInstancedStaticMeshComponent作为载体渲染实例,使用设置自定义数据方法传递给材质,方法如下:
bool UInstancedStaticMeshComponent::SetCustomData(int32 InstanceIndex, TArrayView<const float> InCustomData, bool bMarkRenderStateDirty)
材质中使用PerInstanceCustomData节点来获取这里设置的自定义数值。
自定义数据有两种:
- FAnimToTextureFrameData
- FAnimToTextureAutoPlayData
这里简单讲一下使用第二个数据类型播放的逻辑。数据结构体如下:
USTRUCT(BlueprintType)
struct FAnimToTextureAutoPlayData
{
GENERATED_USTRUCT_BODY()
/**
* Adds offset to time
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AnimToTexture|Playback")
float TimeOffset = 0.0f;
/**
* Rate for increasing and decreasing speed.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AnimToTexture|Playback")
float PlayRate = 1.0f;
/**
* Starting frame for animation.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AnimToTexture|Playback")
float StartFrame = 0.0f;
/**
* Last frame of animation
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AnimToTexture|Playback")
float EndFrame = 1.0f;
};
材质接收数据
GPU渲染动画的主要逻辑入口在/All/EngineData/Plugins/AnimToTexture/Materials/ML_BoneAnimation
这也是在修改静态模型材质时,加入的Material Layer
。
接收动画数据主要有两种方式:
使用动态参数:
直接从IMC的自定义数据获得:
获得当前播放帧
自动播放获得当前播放帧节点:
方法文件路径:/All/EngineData/Plugins/AnimToTexture/Materials/MaterialFunctions/GetAutoPlayFrame
计算公式如下:
Frame = StratFrame + Fmod((Time + TimeOffset) * Playrate * SampleRate, (EndFrames - StartFrame))
SampleRate是30帧每秒,使用Fmod让动画自带循环播放。
采样骨骼权重

这个节点中包含功能:
- 计算权重贴图UV
- 采样影响骨骼
- 采样影响权重
计算权重贴图UV
节点是VertexUVs
:
UVs.y += ((int(Frame) * int(RowsPerFrame) / TextureSize.y)) % TextureSize.y;
采样影响骨骼

采样影响权重

采样骨骼位置和法线

这个节点中包含功能:
- 计算贴图UV
- 采样骨骼位置数据
- 采样旋转数据
- 计算顶点位置
- 计算法线信息
计算骨骼贴图UV
节点是BoneIndexUVs
,计算逻辑代码如下:
// 避免插值问题,所以确保采样点在像素中心,加上偏移量
UV.x = (0.5 / TextureSize.x) + (int(BoneIndex) / TextureSize.x);
UV.y = (0.5 / TextureSize.y) + (int(Frame) / TextureSize.y);
采样骨骼位置数据
因为贴图中存的是标准化的骨骼位置信息,所以在采样贴图后,需要乘上SizeBBox
,再加上MinBBox
。节点是UpackBonePosition
,逻辑如下图:

采样旋转数据
旋转形象也是标准化的,所以需要对轴进行反标准化。因为角度是存的弧度值,所以不需要再对其操作。逻辑如下图:

计算顶点位置
骨骼位置采样需要采了两次,得到以下两个数据:
- 参考姿势下该骨骼的位置,这个存在第0帧。
- 动画当前帧该骨骼的位置。

Pre-Skinned Local Position
返回的是参考姿势下该顶点的位置。
首先旋转节点RotateAboutAxis
的意思是:把参考姿势下该顶点位置
绕当前帧动画骨骼旋转轴
旋转当前帧动画骨骼转向角度
,旋转中心为参考姿势下该骨骼的位置
。
应用当前帧动画的旋转值到顶点上后,再加上当前帧骨骼的位置,就得到局部空间下顶点的位置。最后把该位置转化为世界坐标,减去原点坐标。因为最后返回的是World Position Offset
。
计算法线信息

Pre-Skinned Local Position
返回的是参考姿势下该顶点的法线信息。
旋转节点RotateAboutAxis
的意思是:把参考姿势下该顶点法线
绕当前帧动画骨骼旋转轴
旋转当前帧动画骨骼转向角度
,旋转中心为0
。
应用当前帧动画的旋转值到顶点法线上后,再加上参考姿势下该顶点法线
,得到当前帧顶点法线数据,最后标准化将其数据转到切线空间。
混合骨骼权重
因为一个顶点受到四根骨骼的影响,这是常见对骨骼蒙皮平滑的处理。那这里就需要对四根骨骼都计算出对应该顶点的位置和法线信息:

然后根据四根骨的权重值分别乘上计算出的四组数据,再分别相加就可以了。最终可以使用静态变量UseTwoInfluences
和UseFourInfluences
来控制使用哪种混合。

如果说性能告急或者是精度要求不高可以选择两根骨骼的或者直接使用一根骨骼。
思考
- 提出一个设想,我们可以直接使用权重最大的那个骨骼进行采样。是不是就更加的节省。
- 图片的采样次数有点多,最少一个顶点都是5次:
- UnpackBoneWeights:2次
- BonePositionAndNormal:3次(4根骨骼就是12次)
- 并没有存储骨骼缩放的贴图
- 减少消耗,贴图内容减少,显存减少,采样减少
- 大多数动画中使用骨骼的位置和旋转即可,缩放数据影响不大。
参考
- https://github.com/kromond/AnimToTextureHelpers
- https://dev.epicgames.com/community/learning/tutorials/3xKm/unreal-engine-animtotexture-plugin-how-to-use-it-to-make-vertex-animation-textures-for-crowds
- https://qiita.com/marv_kurushimay/items/8898046ed9986bbdd6b5#スタティックメッシュ
- https://banming.github.io/Graphic/basic/raster/texture.html#贴图映射