本文为论文Mode-Adaptive Neural Networks for Quadruped Motion Control(SIGGRAPH 2018 )的代码解读,算法部分只会描述最终使用的方案,Motivation和具体原理可参考原论文或者:
[合集] Data-Driven Character Motion Synthesis - 知乎 (zhihu.com)
对《Mode-Adaptive Neural Networks for Quadruped Motion Control》一文的理解(上)_Arcobaleno-CSDN博客
原论文代码在github的地址为:https://github.com/sebastianstarke/AI4Animation
输入、输出
输入的具体格式
1 | 这一 state (i) 的Trajectory,所有值都相对于根节点 |
输出的具体格式
1 | 下一 state (i+1) 的 Trajectory |
前向传播
混合公式
门控网络的输出维度为num_experts,相应地存在num_experts个动作预测网络,根据门控网络的输出的混合参数对动作预测网络进行混合。论文中提到作者尝试了num_experts=4和num_experts=8两种设置。
其中系数 是门控网络的输出。
Gating Network(门控网络)
每一层都做了dropout, 为softmax函数。
ELU为激活函数指数ReLU。
Motion Prediction Network(动作预测网络)
每一层都做了dropout。
训练参数和其他细节
使用z-score归一化
由于trot和canter的周期有限,作者将它们的数据各复制了11次。
损失函数:MSE
用SGD和AdamWR中的warm restart技术来训练模型。
AdamWR自带了正则化所以损失函数不包含正则化项。
AdamWR用参数 和 来控制学习率 和权重衰减率 ,初始 ,
是第i次运行的epoch数,初始为10。每次重启时,将 乘以 。
总轮数为150,所以训练过程中一共会重启三次,在第11轮,31轮和71轮时重启。
batch size=32
Dropout保留率为0.7
工作流程
作者使用的游戏引擎为Unity,深度学习库为Tensorflow。
整个流程可以分为3个阶段。
第一阶段做数据预处理。神经网络算法落地的大部分工作都是特征工程,原始的bvh数据没有任何标签,为了给数据打上粗粒度的标签(idle, move, lie, sit, stand, jump),作者选择在Unity中用自定义格式和插件进行可视化的操作,整个打标过程完成后导出成txt格式的训练集。
第二阶段,得到训练集后用TensorFlow训练,训练完成后得到.bin格式的权重文件。
第三阶段便可以在Unity中按照网络结构定义MANN类,读入权重预测每一帧的骨骼位置啦。注意由于网络输入的Trajectory中包含了Style信息,输入神经网络前要根据玩家输入的控制信号修改上一帧的Trajectory。
C#代码文件总览
虽然流程图看上去很简单,但用C#做数据处理和神经网络预测其实是个比较复杂的过程。尤其是数据处理阶段,自定义格式导致了频繁的函数跳转,在没有任何注释的情况下读起代码来非常容易陷入困惑。
整个C#部分用cloc统计一共有9592行C#代码,697行注释,1575行空白。
其中数据预处理相关的代码约3000行,运行时接受输入预测下一帧位置(第三阶段)的代码约2000行,第三方库、数学运算、其他代码共4000多行。
以下是主要代码文件的简单说明。
1 | Assets |
存放数据的ScriptableObject类
ScriptableObject类在Unity中专门用于存放数据,可以被存储为asset文件。
作者为了在Unity中处理数据自定义了MotionData格式,一个MotionData实例就对应于一个bvh文件,包含的内容包括骨骼的结构(Hierarchy类)和捕捉到的动画中每一帧每个关节的位置(Frame类)。为了对动捕数据的运动模式打标、提取运动轨迹数据,又定义了StyleModule类和TrajectoryModule类。每个MotionData实例都需要添加一个StyleModule和一个TrajectoryModule。
MotionData的内部类Sequence指示MotionData中一段时间的数据,默认只有一个从头到尾的sequence。Hierarchy的内部类Bone对应骨骼上的一个关节。
StyleModule类用关键帧的方式对动捕数据进行标记。布尔数组Keys的长度等于动捕文件的帧数,标记每一帧是否为关键帧。GetStyle函数返回one-hot向量表示这一帧的Styles。
StyleFunction类执行实际的标记处理。这里采用了对每个Style分别处理的方式,本文中一共有6个Style,所以StyleModule中有6个StyleFunction。
StyleFunction的Name属性表示该StyleFunction处理哪一个Style,Values数组表示每一帧该Style的值,关键帧的Value为0或者1。标记了关键帧的Value后,StyleFunction的Compute函数对中间帧的Value做线性插值。
TrajectoryModule用于生成Trajectory类型的轨迹数据,TrajectoryModule::GetTrajectory()函数为MotionData中的一帧生成一个Trajectory实例,每个Trajectory类都包含若干个Point。
PhaseModule推测应是实现前一篇论文PFNN时给运动标记相位的模块,在MANN中用不上。
另外,还有一个Parameters类也继承了ScriptableObject,其作用为在游戏运行阶段保存从.bin文件中读取的神经网络权重,实际在第三阶段游戏运行时用到。
MotionEditor组件和EditorWindow类
在Unity中继承EditorWindow类可以在菜单栏生成按钮打开自定义的窗口。
作者用了两个EditorWindow类和一个MotionEditor组件来处理数据,步骤为:首先用BVHImporter从指定的文件夹中导入bvh文件转换成MotionData格式的asset文件,然后用Wolf对象上挂载的MotionEditor组件读取MotionData格式的asset文件,标记动捕数据的Style。最后用MotionExporter将带有标签的数据导出为txt文件当做下一阶段的训练集。
Actor类也是一个组件,其Bones数组指向所有要处理的关节,其他所有类需要处理骨骼的transform时都只需使用Actor的Bones数组,而不用GameObject.Find函数或GameObject.GetChild函数。Actor的ExtractSkeleton方法有两个重载,不要参数的版本从当前挂载的游戏对象上提取骨骼信息,并且提供了筛选关节的GUI界面,需要Transform数组作为参数的版本用于从文件中读取骨骼信息,配合MotionEditor给asset文件打标。
读取骨骼的调用链
调用链1,MotionEditor挂载到GameObject上,点击Import时:
MotionEditor.Import()->MotionEditor.Initialise()->MotionEditor.LoadFile(Files[0])
MotionEditor::Import()先读取MotionData格式的.asset文件存入Files(调用AssetDatabase.FindAssets和AssetDatabase.LoadAssetAtPath),然后调用Initialise()->LoadFile(Files[0])将骨骼数据保存到Instance中。
Files数组放着所有的asset文件,Instance是当前处理的文。Instance和Files的类型为File,File.Data类型为MotionData,
调用链2,任意一处调用MotionEditor.GetActor()时(MotionEditor_Editor.Inspector()函数会循环调用GetActor()):
MotionEditor.GetActor()->MotionEditor.Createskeleton()->Actor.ExtractSkeleton()->GetCurrentFile()
创建Actor,调用actor.ExtractSkeleton(Transform[] bones),从当前激活的Instance(MotionData文件)中加载骨骼保存到Actor.Bones,返回Actor。
MotionExporter::ExportData函数
MotionExporter::ExportData方法开始执行时仅标记了Style,还没有生成Trajectory。MotionExporter::ExportData调用State类的构造函数,State的构造函数又会调用TrajectroyModule.GetTrajectory方法来生成Trajecory。一个State实例中保存着一帧的骨骼和Trajectory,State类的数组中有训练集需要的所有数据。最后MotionExporter::ExportData以Data类为媒介将这些数据写入Input.txt和Output.txt。其中Data类相当于一个tensor。
图中还有一个没提到的FBXImporter类,其代码是导入fbx和Actor结合后导出成MotionData格式的asset,不过在MANN项目中似乎不需要这个操作,可以略过。
训练
MANN结构简单,使用TensorFlow训练的代码很容易看懂。
这里只提一下ExpertWeights.py为神经网络中的一层,其中参数的维度前面加上了个num_experts,也就是说每个ExpertWeights类实例中存放了num_experts组一层的参数,其get_NNweight和get_NNbias根据Gating.py输出的混合参数将这几组参数混合得到真正的参数。
游戏运行时
有了权重文件,接下来要搭建MANN网络读入参数,这里只需要实现前向传播。
像各大深度学习库一样,作者先实现了一个神经网络的基类NeuralNetwork,再定义MANN继承NeuralNetwork实现具体的网络结构,MANN的一些重要参数在Unity Editor中设置。
需要注意,读入参数时先从bin文件读入Parameters类,再从Parameters读入MANN。
调用链1,在Editor点击MANN组件的store parameters时:
NeuralNetwork_Editor.OnInspectorGUI()->NN.StoreParameters()->MANN.StoreParametersDerived()->Parameters.Store()->Parameters.ReadBinary()
从bin文件中读取权重存入Parameter.Matrices
Parameters是ScriptableObject类,临时存放网络权重
调用链2,BioAnimation_Wolf组件Awake时:
BioAnimation_Wolf.Awake()->NN.LoadParameters()->MANN.LoadParametersDerived()->Parameters.Load()
从Parameter.Matrices中加载权重到MANN
InputHandler组件缓存用户输入。Controller类从InputHandler获得用户输入,判断用户输入的Style。每种Style对应的用户输入等参数需要在Unity Editor中设置。
Trajectory类保存游戏对象的轨迹,在MANN的代码实现中Trajectory包含111个Point,输入神经网络时,从0号Point开始每隔10个Point取一个,共有13个Point输入MANN。而MANN输出新Point时,编号非10的倍数的Point由前后两个Point线性插值得到。
SerialIK组件和FootIK组件挂在要做IK的骨骼关节上, MotionEditing组件(注意和前面给动捕数据打标的MotionEditor组件不一样)调用它们执行实际的IK算法。
BioAnimation_Wolf组件负责预测并更新骨骼Transform的整个过程。其Awake方法初始化Trajectory并调用NN.LoadParameters()让MANN从Parameters中读取权重。 其Update方法先调用自身的PredictTrajectory函数,根据用户输入修改当前的Trajecotory,再调用自身的Animate函数,用MANN.Predict函数预测Trajectory和骨骼位置并做插值处理,最后调用MotionEditing处理IK过程。