|
发表于 2022-9-20 14:05:40
|
显示全部楼层
一、前言
在与动画有关的实践中,动画数据本身的准备工作无疑是重中之重。BVH作为最常见的动捕文件格式之一,虚幻引擎对其支持的缺失,无疑给我们对动画系统可能性的探索平添了一些阻碍。本着“因为山就在那里”的精神,在本篇中,让我们把常见的外部手段先放在一边(比如通过DCC对文件格式进行转换),尝试用更加正面的解决方案,来直接为虚幻引擎添加对BVH格式的支持。
本篇推荐的学习资料有:
- Some handy resources for BVH stuff[1]:搜集整理了网络上与BVH格式有关的各类资源。
- Motion Capture File Formats Explained[2]:对BVH格式的全面说明,包含了文件格式本身的介绍以及如何对BVH格式文件进行解析的C++代码示例。
- surrey-canine-mocap[3]:由于上一参考中的代码示例并不完整,且年代相对久远,文章作者当时任教的谢菲尔德大学官网上已经找不到代码源文件。该Github库中有对其源码比较完整的引用。
- Blender源码:既然Blender是开源的,那自然我们可以从这里偷师,BVH格式的支持代码位于Blender\3.1\scripts\addons\io_anim_bvh,通过Python实现。
- ubisoft-laforge-animation-dataset[4]:育碧公开的动捕数据库。正是为了这碗醋,我们才包了这顿饺子。
- Bandai-Namco-Research-Motiondataset:[5]此前万代南梦宫公开的动捕数据库。
<hr/>二、什么是BVH - BioVision Hierarchical Data
BVH全称是BioVision Hierarchical Data,意为由BioVision公司开发的一种层次数据格式。比较遗憾的是,尽管网上许多与BVH格式有关的内容都援引了威斯康辛大学网页[6]或者维基百科词条[7]中的描述,一笔带过式地介绍了BioVision是一家动捕服务公司:
The Biovision Hierarchy (BVH) character animation file format was developed by Biovision, a defunct motion capture services company, to give motion capture data to customers - Wikipedia 但除此之外,我并未能够找到与这家公司有关的进一步的信息。如今网上占据着BioVision这个名字的已经是一家生物试剂的生产商与一家致力于保护非洲自然环境的慈善组织。而我们这里想要了解的BioVision,与他有关的内容,就只剩下至今仍在广泛使用的BVH动捕文件格式了,不过就连这个格式缩写的使用也有隔壁的Bounding Volume Hierarchy分庭抗礼。
1.层级 - HIERARCHY
扯远了,让我们回到正题。以育碧的数据库为例,我们打开一个BVH文件(walk1_subject1.bvh),会发现它由两部分组成。首先是以HIERARCHY关键词开头的层次结构,它记录了关节(JOINT)的名称、关节间的父子关系(通过“{}”表示)、关节的偏移(OFFSET)以及各个关节在动画数据中需要应用的变换通道(CHANNELS),包括位置(XYZposition)、旋转(XYZrotation)和缩放(XYZscale):
HIERARCHY
ROOT Hips
{
OFFSET 173.408295 91.952423 -518.280273
CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation
JOINT LeftUpLeg
{
OFFSET 0.103459 1.857827 10.548504
CHANNELS 3 Zrotation Yrotation Xrotation
JOINT LeftLeg
{
OFFSET 43.500008 0.000000 0.000004
CHANNELS 3 Zrotation Yrotation Xrotation
JOINT LeftFoot
{
OFFSET 42.372192 0.000011 0.000000
CHANNELS 3 Zrotation Yrotation Xrotation
JOINT LeftToe
{
OFFSET 17.299973 -0.000013 -0.000010
CHANNELS 3 Zrotation Yrotation Xrotation
End Site
{
OFFSET 0.000000 0.000000 0.000000
}
}
}
}
}
JOINT RightUpLeg
{
......
}
......
}
与JOINT类似,ROOT和End Site同样标记了关节的名称,并对应着树状结构中的一个节点。ROOT作为根节点标记了一副骨骼的开始,理论上一个BVH文件中允许同时存在多副骨骼。而End Site标记了某一分支的结束,即该结点不再有子节点,便于我们后续对文件进行解析时递归遍历树状结构中的数据。 这部分内容实际上就对应了我们在引擎中需要的骨骼(Skeleton)数据,我们可以将育碧提供的FBX格式的模型文件导入引擎来做一下对比(图1):

图1 model_skeleton.fbx
我们可以发现,关节的层次结构与虚幻引擎内骨骼资产的骨骼树(Bone Tree)相对应(图2):

图2 HIERARCHY to Bone Tree
需要注意的是,育碧提供的BVH文件中骨骼结构的根节点(ROOT)为Hips,并没有遵循虚幻的惯例额外创建根骨骼,这点在我们后面生成根骨骼位移数据时会再次提到。 关节的偏移量与骨骼资产的参考姿势(Reference Pose)中骨骼的相对位置(Relative Location)相对应,同时也决定了骨骼的长度(Length)和方向(Orientation)(图3):

图3 JOINT OFFSET to Bone Relative Location
最后关于关节的变换通道,虽然支持位置、旋转以及缩放,不过和虚幻引擎中一样,一般情况下使用的只有旋转通道,位置通道仅见于根节点用来记录角色的整体位移,至于缩放通道,动画数据尤其是动捕数据中几乎不会使用。
2.动作 - MOTION
第二部分是以MOTION关键词开头的实际动画数据,记录了帧数(Frames),帧间隔时间(FrameTime),以及对应第一部分中所需应用的变换通道(CHANNELS)的每一帧的变换数据:
MOTION
Frames: 7840
Frame Time: 0.033333
173.408295 91.952423 -518.280273 89.620408 0.652150 92.222382 175.529305 -3.825606 173.027898 -16.027724 0.907516 2.303384 76.117670 -2.318585 -2.386197 21.454555 0.003095 0.000000 177.801203 2.282903 -176.783696 -14.571799 4.189206 6.242791 77.874716 -3.774645 2.921458 21.454560 -0.003101 -0.000015 5.477288 0.241115 0.396648 0.943347 0.474440 0.795290 0.625275 0.479302 0.792255 0.966223 6.327928 -2.427240 14.907216 -14.144141 -0.363863 -64.744039 -84.244694 -117.560273 -11.211029 3.037350 -15.035068 -8.166726 -10.412443 0.705441 12.859502 1.553254 18.358731 -82.670322 80.131137 99.953589 -3.698083 -8.076120 16.001649 -15.291380 15.341529 -1.080976 12.298188 10.353672 -7.473871
173.419205 91.952507 -518.296814 89.627122 0.652145 92.245919 175.514524 -3.809597 173.039154 -16.025599 0.907515 2.309624 76.108504 -2.318579 -2.375044 21.454550 0.003080 -0.000001 177.790480 2.294005 -176.772713 -14.577219 4.181992 6.249023 77.874709 -3.774639 2.935679 21.454558 -0.003111 -0.000016 5.483439 0.241118 0.396645 0.955701 0.479167 0.795298 0.637622 0.484056 0.792258 0.966232 6.328626 -2.427244 14.872445 -14.144148 -0.360162 -64.767911 -84.235097 -117.537174 -11.196231 3.008290 -14.961147 -8.168670 -10.412442 0.705449 12.836450 1.561534 18.432874 -82.663307 80.112204 99.962421 -3.628556 -8.074670 15.968004 -15.267870 15.341529 -1.080978 12.263732 10.330902 -7.610445
173.436996 91.953293 -518.325073 89.639102 0.645258 92.286859 175.487354 -3.781093 173.058115 -16.014270 0.907509 2.320428 76.088553 -2.318591 -2.357747 21.454553 0.003096 -0.000000 177.766574 2.315092 -176.748179 -14.585862 4.172627 6.256774 77.874723 -3.774633 2.954751 21.454555 -0.003107 -0.000018 5.492920 0.246328 0.396650 0.974693 0.489511 0.795292 0.656620 0.494427 0.792255 0.971382 6.312130 -2.427249 14.782444 -14.144151 -0.352229 -64.869182 -84.223042 -117.437193 -11.153772 2.927034 -14.808076 -8.178589 -10.412440 0.705453 12.805461 1.581273 18.555023 -82.619396 80.077063 100.009795 -3.505154 -8.040449 15.907746 -15.226160 15.340822 -1.080973 12.210749 10.263810 -7.805514
173.452805 91.954536 -518.354004 89.653323 0.634049 92.335688 175.458353 -3.751610 173.087471 -15.995224 0.907506 2.329874 76.062981 -2.318583 -2.341208 21.454551 0.003085 -0.000001 177.738488 2.340188 -176.710723 -14.596446 4.167201 6.261426 77.874716 -3.774637 2.967331 21.454560 -0.003113 -0.000017 5.501727 0.251891 0.396654 0.992354 0.500570 0.795292 0.674280 0.505511 0.792254 0.979462 6.286225 -2.423503 14.698909 -14.143165 -0.342941 -64.993423 -84.193392 -117.315794 -11.106748 2.822022 -14.664257 -8.216682 -10.414490 0.705454 12.778589 1.553161 18.647598 -82.549659 80.030617 100.084128 -3.377119 -7.977191 15.856028 -15.210020 15.365479 -1.080973 12.171469 10.173300 -7.933570
......
这些变换数据的顺序与第一部分HIERARCHY中关节以及关节所需变换通道的出现顺序一一对应,比如变换数据第一行中的前六个数据对应的就是Hips的XYZposition以及ZYXrotation(注意通道顺序)(图4):

图4 Hips Transform of First Frame
将这些数据依次填到虚幻引擎内动画序列(Animation Sequence)的每一帧的每一根骨骼的每一个变换通道中,我们就能得到我们想要的动画资产(图5):

图5 walk1_subject1
<hr/>三、如何将BVH文件导入虚幻引擎
1.引擎中的BVH数据结构
首先,在将动画数据导入动画序列之前,我们需要准备一个中间数据结构用来存储从BVH文件导入的数据,本质上是一个还原骨骼层次的树状结构,我们为每一根骨骼创建一个节点(Node),并记录他的父节点、所有的子节点以及该节点的所有骨骼信息与动画数据,这里为了方便后续在引擎中的使用,数据均采用虚幻提供的类型:
struct FNode
{
......
FName Name; // Name of bone
float Length; // Length of segment along the Y-Axis
float Length2; // Square of length for fast calculation
FVector LocalEnd; // Length of segment
FVector Offset; // Transitional offset with respect to the end of the parent link
FVector Euler; // Rotation of the base position
int NumOfChildren; // Number of child nodes
TArray<FNode*> Children; // Array of pointers to child nodes
FNode* Parent; // Back pointer to parent node
TArray<FVector> FrameOffset; // Array of offsets for each frame
TArray<FVector> FrameEuler; // Array of angles for each frame
TArray<FQuat> FrameQuat; // Array of Quats for each frame
TArray<FVector> FrameScale; // Array of scale factors for each frame
BYTE DOFs; // Used to determine what DOFs the segment has
int NumOfChannels; // Num of channels need to be applied
......
};
此外我们还需要另准备一个结构体(MocapHeader)用来存放一些用于描述整个动画文件的数据,比如骨骼总数、帧数、帧率以及遍历动画数据时的当前帧等:
struct FMocapHeader
{
......
int NumOfSegments; // Number of body segments
long NumOfFrames; // Num of frames
int DataRate; // Num of frames per seconds
float FrameTime; // Interval between frames
TArray<FVector> Euler; // Specifies how the euler angle is defined
float Calibration; // Scale factor for converting current translation units into meters
bool bDegrees; // Are the rotational measurements in degrees
float ScaleFactor; // Global scale factor
long CurrentFrame; // Stores the current frame to render
......
};
将以上两部分组合起来,就是我们在引擎中实际使用的BVH数据格式(BVHFormat)了:
struct FBVHFormat
{
......
FNode* Root;
TArray<FNode*> NodeList;
FMocapHeader* Header;
char Error[255];
......
};
2.对BVH文件进行语法分析
定义好了数据结构,我们就可以开始对BVH文件作语法分析(Parsing)了。这里我们通过标准库来实现BVH文件的IO以及对字符串的处理:
#include <stdio.h>
#include <string.h>
#include <math.h>
整个数据导入的实现主要集中在以下这个函数,为了方便参考使用以及保证代码阅读体验的连贯,我们这里先将完整的函数贴出:
bool FBVHFormat::ImportData(const char* FileName)
{
int Read, i, j, Where;
int Pos[8]; // Used to determine the position of the next char to write
char Line[8][40]; // Used to store the attribute and the corresponding value
char Buffer[4097];
int Section = 0; // Indicates which section is currently being processed
FNode* CurrentNode = nullptr; // Used to indicate the current node that is being processed
int Index = 0, Channels = 0;
bool EndSite = false;
ResetState();
FILE* File = nullptr;
fopen_s(&File, FileName, &#34;rb&#34;);
if (File)
{
// Process the &#34;Hierarchy&#34; section of the file
Read = fread(Buffer, 1, 4096, File);
Buffer[Read] = &#39;\0&#39;;
i = StrstrEx(Buffer, &#34;HIERARCHY&#34;);
i += StrstrEx(Buffer + i, static_cast<char>(10));
while (Buffer[++i] < 32);
Where = Pos[0] = Pos[1] = Pos[2] = Pos[3] = Pos[4] = Pos[5] = Pos[6] = Pos[7] = 0;
// Process each line in the header
while (Read)
{
while (i < Read)
{
if ((Buffer == static_cast<char>(10) && Pos[0])
|| (Section == 2 && Where == 3))
{
// Process line
Line[7][Pos[7]] = Line[6][Pos[6]] = Line[5][Pos[5]] = Line[4][Pos[4]] = Line[3][Pos[3]] = Line[2][Pos[2]] = Line[1][Pos[1]] = Line[0][Pos[0]] = &#39;\0&#39;;
if (!Section)
{
// Process Hierarchy
if (StrCompEx(Line[0], &#34;ROOT&#34;))
{
if (Root)
{
strcpy_s(Error, &#34;BVH file contains more than one skeleton which is currently unsupported&#34;);
fclose(File);
return false;
}
else
{
Root = new FNode;
NodeList.Add(Root);
Header->NumOfSegments++;
Root->Name = Line[1];
CurrentNode = Root;
}
}
else if (StrCompEx(Line[0], &#34;JOINT&#34;))
{
CurrentNode->IncreaseNumOfChildren();
CurrentNode = CurrentNode->GetLastChild();
NodeList.Add(CurrentNode);
Header->NumOfSegments++;
CurrentNode->Name = Line[1];
}
else if (StrCompEx(Line[0], &#34;OFFSET&#34;))
{
const float x = static_cast<float>(atof(Line[1])) * Header->Calibration;
const float y = static_cast<float>(atof(Line[2])) * Header->Calibration;
const float z = static_cast<float>(atof(Line[3])) * Header->Calibration;
const FVector NewOffset(x, y, z);
if (!EndSite)
{
CurrentNode->Offset = NewOffset;
if (CurrentNode != Root && (CurrentNode->Parent->LocalEnd == FVector::ZeroVector))
{
CurrentNode->Parent->SetLocalEnd(NewOffset);
}
}
else
{
CurrentNode->SetLocalEnd(NewOffset);
}
}
else if (StrCompEx(Line[0], &#34;CHANNELS&#34;) && !EndSite)
{
Channels += atoi(Line[1]);
CurrentNode->NumOfChannels = atoi(Line[1]);
int d = 2;
while (Line[d] && d < 8)
{
if ((Line[d][0] & 0xdf) == &#39;X&#39;)
{
if ((Line[d][1] & 0xdf) == &#39;R&#39;)
{
CurrentNode->DOFs |= XROT;
}
else if ((Line[d][1] & 0xdf) == &#39;P&#39;)
CurrentNode->DOFs |= XTRA;
}
else if ((Line[d][0] & 0xdf) == &#39;Y&#39;)
{
if ((Line[d][1] & 0xdf) == &#39;R&#39;)
{
CurrentNode->DOFs |= YROT;
}
else if ((Line[d][1] & 0xdf) == &#39;P&#39;)
CurrentNode->DOFs |= YTRA;
}
else if ((Line[d][0] & 0xdf) == &#39;Z&#39;)
{
if ((Line[d][1] & 0xdf) == &#39;R&#39;)
{
CurrentNode->DOFs |= ZROT;
}
else if ((Line[d][1] & 0xdf) == &#39;P&#39;)
CurrentNode->DOFs |= ZTRA;
}
++d;
}
}
else if (StrCompEx(Line[0], &#34;END&#34;) && StrCompEx(Line[1], &#34;SITE&#34;))
EndSite = true;
else if (Line[0][0] == &#39;}&#39;)
{
if (EndSite)
EndSite = false;
else
CurrentNode = CurrentNode->Parent;
}
else if (StrCompEx(Line[0], &#34;MOTION&#34;))
{
++Section;
}
}
else if (Section == 1)
{
// Process Motion
if (StrCompEx(Line[0], &#34;FRAMES:&#34;))
{
Header->NumOfFrames = atoi(Line[1]);
for (int k = 0; k < Header->NumOfSegments; ++k)
NodeList[k]->ResizeFrameNum(Header->NumOfFrames);
Header->CurrentFrame = 0;
}
else if (StrCompEx(Line[0], &#34;FRAME&#34;) && StrCompEx(Line[1], &#34;TIME:&#34;))
{
Header->FrameTime = atof(Line[2]);
Header->DataRate = static_cast<int>(1 / (atof(Line[2])));
if (static_cast<int>(0.49 + (1 / atof(Line[2]))) > Header->DataRate)
++Header->DataRate;
}
if (Header->DataRate && Header->NumOfFrames)
{
++Section;
CurrentNode = Root;
Index = 0;
EndSite = false;
}
}
else
{
//Process DOFs
if (Header->CurrentFrame < Header->NumOfFrames)
{
FVector Offset(atof(Line[0]), -atof(Line[1]), atof(Line[2]));
FVector Euler(atof(Line[2]), -atof(Line[1]), -atof(Line[0]));
const FQuat Quat = FQuat::MakeFromEuler(Euler);
if (CurrentNode->DOFs == 231/* XRot|YRot|ZRot|XTra|YTra|ZTra */)
{
if (!EndSite)
{
CurrentNode->FrameOffset[Header->CurrentFrame] = Offset * Header->Calibration;
EndSite = true;
}
else
{
CurrentNode->FrameEuler[Header->CurrentFrame] = Euler;
CurrentNode->FrameQuat[Header->CurrentFrame] = Quat;
CurrentNode->FrameScale[Header->CurrentFrame] = FVector::OneVector;
CurrentNode = NodeList[++Index];
EndSite = false;
}
}
else
{
CurrentNode->FrameOffset[Header->CurrentFrame] = CurrentNode->Offset * Header->Calibration;
CurrentNode->FrameEuler[Header->CurrentFrame] = Euler;
CurrentNode->FrameQuat[Header->CurrentFrame] = Quat;
CurrentNode->FrameScale[Header->CurrentFrame] = FVector::OneVector;
if (Index + 1 < Header->NumOfSegments)
CurrentNode = NodeList[++Index];
else
{
++Header->CurrentFrame;
CurrentNode = NodeList[Index = 0];
}
}
}
else
++Section;
}
if (Section != 2)
{
// Move onto the next line and clear current line information
j = StrstrEx(Buffer + i, static_cast<char>(10));
if (j == -1)
{
if (Buffer[4095] != 10)
{
Read = fread(Buffer, 1, 4096, File);
i = StrstrEx(Buffer, static_cast<char>(10));
}
else
{
Read = fread(Buffer, 1, 4096, File);
i = 0;
}
Buffer[4096] = &#39;\0&#39;;
}
else
i += j;
}
Where = Pos[0] = Pos[1] = Pos[2] = Pos[3] = Pos[4] = Pos[5] = Pos[6] = Pos[7] = 0;
}
if (Buffer > 44 && Buffer < 126)
Line[Where][Pos[Where]++] = Buffer[i++];
else if ((Buffer == 32 || Buffer == 9) && Pos[Where] > 0)
{
++Where;
++i;
}
else
++i;
}
Read = fread(Buffer, 1, 4096, File);
Buffer[4096] = &#39;\0&#39;;
i = 0;
}
fclose(File);
return true;
}
else
{
strcpy_s(Error, &#34;Cannot Open File&#34;);
return false;
}
}
我个人觉得,对于此前没有做过数据格式语法分析相关工作的的同学(包括我)来说,这次的实践对未来各类基于ASCII可读明文文件的导入导出工作都具有一定的参考价值。所以在进入下一个环节之前,我们还是花一点篇幅,来简单看看所谓语法分析(Parsing)都做了些什么。 ImportData这个函数由外向内大致做了下面这三件事情:
- 文件的读取:首先要做的就是将文件读取进来,通过指定的路径打开(fopen_s)我们所需要导入的BVH格式文件,然后将文件的内容以字符的形式读取(fread)至我们事先准备的Buffer中,并遍历Buffer中的内容,重复读取与遍历直至将文件中的所有内容处理完毕,最后关闭这个文件(fclose):
FILE* File = nullptr;
fopen_s(&File, FileName, &#34;rb&#34;);
if (File)
{
Read = fread(Buffer, 1, 4096, File);
Buffer[Read] = &#39;\0&#39;;
i = 0;
// Do something here #1.
......
while (Read)
{
while (i < Read)
{
// Do something here #2.
......
}
Read = fread(Buffer, 1, 4096, File);
Buffer[4096] = &#39;\0&#39;;
i = 0;
}
fclose(File);
return true;
}
else
{
strcpy_s(Error, &#34;Cannot Open File&#34;);
return false;
}
需要注意的是,在真正开始处理文件的内容之前(Do something here #1),通过关键词HIERARCHY的查找(StrstrEx),能够帮助我们更快地定位到文件中对我们有实际意义的内容:
i = StrstrEx(Buffer, &#34;HIERARCHY&#34;);
i += StrstrEx(Buffer + i, static_cast<char>(10));
while (Buffer[++i] < 32);
ASCII:10为换行,32为空格。
- 格式分析:在Buffer遍历的主体部分(Do something here #2),我们需要对文件的内容根据其格式进行拆解。拆解过后的数据将会临时存放在二维数组Line[][]中。通过对文件内容的观察,很容易发现起决定性作用的字符主要有两种,其一是“换行(10)”,和字面意思一样用以区隔每一行的内容,我们暂且将其称为一条属性(Attribute),可以看到这里属性的名称涵盖了我们之前提到的所有关键词:
if ((Buffer == static_cast<char>(10) && Pos[0])
|| (Section == 2 && Where == 3))
{
// Process line
Line[7][Pos[7]] = Line[6][Pos[6]] = Line[5][Pos[5]] = Line[4][Pos[4]] = Line[3][Pos[3]] = Line[2][Pos[2]] = Line[1][Pos[1]] = Line[0][Pos[0]] = &#39;\0&#39;;
if (!Section)
{
// Process Hierarchy
if (StrCompEx(Line[0], &#34;ROOT&#34;))
{
// Do something here #3
......
}
else if (StrCompEx(Line[0], &#34;JOINT&#34;))
{
......
}
else if (StrCompEx(Line[0], &#34;OFFSET&#34;))
{
......
}
else if (StrCompEx(Line[0], &#34;CHANNELS&#34;) && !EndSite)
{
......
}
else if (StrCompEx(Line[0], &#34;END&#34;) && StrCompEx(Line[1], &#34;SITE&#34;))
{
......
}
else if (Line[0][0] == &#39;}&#39;)
{
......
}
else if (StrCompEx(Line[0], &#34;MOTION&#34;))
{
++Section;
}
}
else if (Section == 1)
{
// Process Motion
if (StrCompEx(Line[0], &#34;FRAMES:&#34;))
{
......
}
else if (StrCompEx(Line[0], &#34;FRAME&#34;) && StrCompEx(Line[1], &#34;TIME:&#34;))
{
......
}
if (Header->DataRate && Header->NumOfFrames)
{
++Section;
......
}
}
else
{
//Process DOFs
if (Header->CurrentFrame < Header->NumOfFrames)
{
......
}
else
++Section;
}
if (Section != 2)
{
// Move onto the next line and clear current line information
......
}
Where = Pos[0] = Pos[1] = Pos[2] = Pos[3] = Pos[4] = Pos[5] = Pos[6] = Pos[7] = 0;
}
其二是以“空格(32)”为界,用以区隔一条属性中的属性名以及与其匹配的实际数据的数值。同时我们在这里对之前提到的二维数组Line[][]的内容进行填写,第一个下标对应了是这一行中的第几个单词或者数值,第二个下标对应了是这个单词或者数值中的第几个字符:
if (Buffer > 44 && Buffer < 126)
Line[Where][Pos[Where]++] = Buffer[i++];
else if ((Buffer == 32 || Buffer == 9) && Pos[Where] > 0)
{
++Where;
++i;
}
else
++i;
ASCII:32~126为可显示字符,其中44为逗号“,”126为波浪号“~”,另外9为水平制表符即Tab。
- 内容分析:最后要做的就是在格式分析每一行的内容时(Do something here #3),针对我们存储在这一行(Line[][])中的属性的名称或者说关键词(Line[0])进行判断,并执行对应的操作。这里我们就不再一一展开,仅以MOTION中的FRAMES为例,可以看到具体的操作大体分为两个步骤,一是将这一行中的数值部分(Line[1])转换为我们所需要的数据类型,二是将转换后的结果填写到我们之前预先定义过的数据结构中:
if (StrCompEx(Line[0], &#34;FRAMES:&#34;))
{
Header->NumOfFrames = atoi(Line[1]);
for (int k = 0; k < Header->NumOfSegments; ++k)
NodeList[k]->ResizeFrameNum(Header->NumOfFrames);
Header->CurrentFrame = 0;
}
正如之前提到过的,FRAMES用于描述动画的帧的总数,所以此处将数值填写至FMocapHeader中,如果是骨骼的数据或者是每一帧的动画数据则填写至由FNode组成的树状结构中。 如果对这部分有疑问,可以参看上面的完整代码或者推荐学习资料【2】【3】。另外需要注意的是,本文代码中给出的旋转朝向针对育碧动捕库进行过处理,不保证适用所有的BVH格式文件。
3.将引擎中的BVH数据写入动画序列
将数据由BVH文件读取至自定义数据结构后,我们还需将数据写入动画序列,我们通过获取动画控制器来对数据进行写入:
void BVHImporter::ExtractAnimDataFromBVHFile(UAnimSequence* AnimSequence, FString FileName)
{
FBVHFormat BVHFile;
if (AnimSequence && BVHFile.ImportData(TCHAR_TO_ANSI(*FileName)))
{
IAnimationDataController& Controller = AnimSequence->GetController();
Controller.OpenBracket(LOCTEXT(&#34;ImportBVHAnimation&#34;, &#34;Importing BVH Animation&#34;));
Controller.ResetModel();
Controller.SetPlayLength(BVHFile.Header->GetPlayLength());
Controller.SetFrameRate(FFrameRate(BVHFile.Header->DataRate, 1));
AnimSequence->ImportFileFramerate = BVHFile.Header->DataRate;
AnimSequence->ImportResampleFramerate = BVHFile.Header->DataRate;
// Write animation data into animation sequence.
......
......
Controller.NotifyPopulated();
Controller.CloseBracket();
AnimSequence->PostEditChange();
AnimSequence->MarkPackageDirty();
}
}
我们在上述函数的省略部分进行实际的数据写入操作,分为两个部分,首先由于我们所使用的BVH文件根节点为髋部(Hips),所以我们需要额外创建一个根骨骼来存储根骨骼位移数据,我们先以根骨骼在原点为基准,记录髋部的位置,然后将髋部水平位置归零来反算根骨骼的位置:
// Extract transform of hip to create root motion.
const FReferenceSkeleton& RefSkeleton = AnimSequence->GetSkeleton()->GetReferenceSkeleton();
const FName RootName = RefSkeleton.GetBoneName(0);
const int32 NumOfKeys = BVHFile.Root->FrameOffset.Num();
TArray<FVector> RootOffsets;
RootOffsets.Reserve(NumOfKeys);
TArray<FQuat> RootQuats;
RootQuats.Reserve(NumOfKeys);
TArray<FVector> RootScales;
RootScales.Reserve(NumOfKeys);
for (int32 i = 0; i < NumOfKeys; i++)
{
// Try to determine root transform first then calculate the hip transform.
FTransform OriginRootTransformLocal = RefSkeleton.GetRefBonePose()[0];
FTransform OriginHipTransformLocal;
OriginHipTransformLocal.SetLocation(BVHFile.Root->FrameOffset);
OriginHipTransformLocal.SetRotation(BVHFile.Root->FrameQuat);
OriginHipTransformLocal.SetScale3D(BVHFile.Root->FrameScale);
FTransform OriginHipTransformWorld = OriginHipTransformLocal * OriginRootTransformLocal;
// In UbiSoft LaForge Dataset, root bone&#39;s positive z faces to positive y in anim space,
// root bone&#39;s negative y faces to positive z in anim space.
FTransform NewRootTransformLocal;
NewRootTransformLocal.SetLocation(FVector(OriginHipTransformWorld.GetLocation().X, OriginHipTransformWorld.GetLocation().Y, 0.0f));
NewRootTransformLocal.SetRotation(UKismetMathLibrary::MakeRotFromYZ(FVector(0.0f,0.0f, -1.0f), FVector::CrossProduct(FVector(0.0f,0.0f,1.0f), OriginHipTransformWorld.GetRotation().GetUpVector())).Quaternion());
NewRootTransformLocal.SetScale3D(OriginHipTransformLocal.GetScale3D());
/** A * B = C
* (A * B)^T = B^T * A^T = C^T
* B^T^(-1) * B^T * A^T = B^T^(-1) * C^T
* I * A^T = B^T^(-1) * C^T
* A = (B^T^(-1) * C^T)^T
**/
FTransform NewHipTransformLocal = FTransform((NewRootTransformLocal.ToMatrixWithScale().GetTransposed().Inverse() * OriginHipTransformWorld.ToMatrixWithScale().GetTransposed()).GetTransposed());
RootOffsets.Add(NewRootTransformLocal.GetLocation());
RootQuats.Add(NewRootTransformLocal.GetRotation());
RootScales.Add(NewRootTransformLocal.GetScale3D());
BVHFile.Root->FrameOffset = NewHipTransformLocal.GetLocation();
BVHFile.Root->FrameQuat = NewHipTransformLocal.GetRotation();
BVHFile.Root->FrameEuler = NewHipTransformLocal.GetRotation().Euler();
BVHFile.Root->FrameScale = NewHipTransformLocal.GetScale3D();
}
处理完根骨骼后,我们再递归遍历所有骨骼节点,并将其数据写入动画序列:
void BVHImporter::RecursiveReadKeysFromNode(IAnimationDataController& Controller, FBVHFormat::FNode* Node)
{
if (Node)
{
Controller.AddBoneTrack(Node->Name);
Controller.SetBoneTrackKeys(Node->Name, Node->FrameOffset, Node->FrameQuat, Node->FrameScale);
if (Node->Children.Num() > 0)
{
for (FBVHFormat::FNode* Child : Node->Children)
{
RecursiveReadKeysFromNode(Controller, Child);
}
}
}
}
4.创建动画序列资产
现在我们已经有了一个记录着BVH动画数据的动画序列了,最后还剩下实际的导入操作,如何拖拽入一个BVH格式文件,并在内容浏览器内创建一个动画序列资产。虚幻引擎中针对文件导入的固定套路相对简单,我们只需要实现一个工厂类即可:
#pragma once
#include &#34;Factories/Factory.h&#34;
#include &#34;BVHImportFactory.generated.h&#34;
UCLASS()
class UBVHImportFactory : public UFactory
{
GENERATED_UCLASS_BODY()
......
public:
//~ Begin UFactory Interface
virtual FText GetDisplayName() const override;
virtual UObject* FactoryCreateFile(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, const FString& Filename, const TCHAR* Parms, FFeedbackContext* Warn, bool& bOutOperationCanceled) override;
virtual bool ShouldShowInNewMenu() const override {return true;};
virtual bool ConfigureProperties() override;
//~ End UFactory Interface
......
};
通过在构造函数中添加对应格式的拓展名,来实现导入的支持:
UBVHImportFactory::UBVHImportFactory(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
......
Formats.Add(TEXT(&#34;bvh;BioVision Hierarchical Data&#34;));
......
}
由于BVH为动画文件,需要指定骨骼,可以通过在导入时创建资产选择窗口实现:
bool UBVHImportFactory::ConfigureProperties()
{
// Null the skeleton so we can check for selection later
Skeleton = nullptr;
// Load the content browser module to display an asset picker
const FContentBrowserModule& ContentBrowserModule = FModuleManager::LoadModuleChecked<FContentBrowserModule>(&#34;ContentBrowser&#34;);
FAssetPickerConfig AssetPickerConfig;
// The asset picker will only show skeletons
AssetPickerConfig.Filter.ClassNames.Add(USkeleton::StaticClass()->GetFName());
AssetPickerConfig.Filter.bRecursiveClasses = true;
// The delegate that fires when an asset was selected
AssetPickerConfig.OnAssetSelected = FOnAssetSelected::CreateUObject(this, &UBVHImportFactory::OnSkeletonSelected);
// The default view mode should be a list view
AssetPickerConfig.InitialAssetViewType = EAssetViewType::List;
PickerWindow = SNew(SWindow)
.Title(LOCTEXT(&#34;CreateAnimSequenceFromBVHOptions&#34;, &#34;Pick Skeleton&#34;))
.ClientSize(FVector2D(500, 600))
.SupportsMinimize(false)
.SupportsMaximize(false)
[
SNew(SBorder)
.BorderImage(FEditorStyle::GetBrush(&#34;Menu.Background&#34;))
[
ContentBrowserModule.Get().CreateAssetPicker(AssetPickerConfig)
]
];
GEditor->EditorAddModalWindow(PickerWindow.ToSharedRef());
PickerWindow.Reset();
return Skeleton != nullptr;
}
void UBVHImportFactory::OnSkeletonSelected(const FAssetData& SelectedAsset)
{
// Set skeleton and destroy picker window.
Skeleton = Cast<USkeleton>(SelectedAsset.GetAsset());
PreviewMesh = Skeleton->GetPreviewMesh();
PickerWindow->RequestDestroyWindow();
}
最后创建UAnimSequence并将BVH文件数据写入即大功告成:
UObject* UBVHImportFactory::FactoryCreateFile(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, const FString& Filename, const TCHAR* Parms, FFeedbackContext* Warn, bool& bOutOperationCanceled)
{
if (Skeleton)
{
// Copy from USDSkelRootTranslator.cpp
// The UAnimSequence can&#39;t be created with the RF_Transactional flag, or else it will be serialized without
// Bone/CurveCompressionSettings. Undoing that transaction would call UAnimSequence::Serialize with nullptr values for both, which crashes.
// Besides, this particular asset type is only ever created when we import to content folder assets (so never for realtime), and
// in that case we don&#39;t need it to be transactional anyway
UAnimSequence* AnimSequence = NewObject<UAnimSequence>(InParent, InName, Flags & ~EObjectFlags::RF_Transactional);
AnimSequence->SetSkeleton(Skeleton);
AnimSequence->SetPreviewMesh(PreviewMesh);
BVHImporter::ExtractAnimDataFromBVHFile(AnimSequence, Filename);
return AnimSequence;
}
return nullptr;
}
所有的工作都完成后,我们就能够在虚幻引擎中进行下面的导入操作了(图6),完结撒花:

图6 Import BVH into Unreal Engine
<hr/>本文发于2022-9
参考
- ^Some handy resources for BVH stuff http://www.cs.man.ac.uk/~toby/bvh/
- ^Motion Capture File Formats Explained http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.103.2097&rep=rep1&type=pdf
- ^ surrey-canine-mocap https://github.com/Era-Dorta/surrey-canine-mocap
- ^ubisoft-laforge-animation-dataset https://github.com/ubisoft/ubisoft-laforge-animation-dataset
- ^Bandai-Namco-Research-Motiondataset https://github.com/BandaiNamcoResearchInc/Bandai-Namco-Research-Motiondataset
- ^Biovision BVH https://research.cs.wisc.edu/graphics/Courses/cs-838-1999/Jeff/BVH.html
- ^Biovision Hierarchy https://en.wikipedia.org/wiki/Biovision_Hierarchy
|
|