这个端午节我没有继续写教程,而是捡起了一个被我暂停了的兴趣项目:洛奇口袋版。
之所以暂停这个项目,主要是因为开发过程中遇到了一些古怪的问题,无论我怎么调试,都搞不清楚错在哪里。
- 播放骨骼动画时,角色模型被肢解了。
- 我希望通过手动拖拽进度条来控制动画,但是我不知道该怎么实现。
- 模型的影子会突然消失。
- 角色模型的部件之间有裂缝。
- 人类/精灵的模型原点在肚脐处,不在脚底。这导致骨骼动画绑定异常。
经过几个月的学习之后,在这个端午节总算是解决了前2个问题,其他问题我也有信心解决了。
项目介绍
首先我还是要介绍一下这个项目。
“洛奇(Mabinogi)”是一款韩国NEXON公司出品的生活类3DMMORPG,我从大学开始玩这个游戏,至今断断续续也有好些年了。
洛奇口袋版(MabiPocketEdition)是我在学习jMonkeyEngine之后,一时兴起构思的项目。最初我只想把洛奇中的游戏场景、角色模型搬到手机上,实现一个单机版的纸娃娃系统,这样我就可以在手机上捏人玩。
后来我想,不如把服装染色、离线查看广告板、每日任务提示等功能也做进去,这样做成一个手机App,或许可以方便洛奇的玩家。
不过想归想,首先要能够实现一个纸娃娃系统才行,否则其他都是虚的。目前我做成的系统是这样的。
技术难点
Pack文件解析
Mabinogi的大部分游戏资源是存储在.pack包中的,其中的文件数据经过了加密、压缩。
关于pack文件的作用,我通过阅读“wo3”的这个帖子弄明白了大概。
http://yydzh.com/read.php?tid=672451
Pack文件的解析过程并不复杂,难点在于提取出Pack中的文件数据。Pack中的文件首先被zlib算法压缩,然后又使用mt19937伪随机算法进行XOR加密。
在解析过程中,我大量参考了“海森堡”的MabinogiPackageTool源码。
https://github.com/darknesstm/MabinogiPackageTool
这是我头一次见到这种加密方式,感觉还是蛮新奇的。每个pack包中记录了一个随机数种子(一般就是资源的版本号),在加密的时候,首先根据这个种子来初始化mt19937算法;然后对于每一个字节,都通过mt19937生成一个随机数,并用这个随机数对字节书记进行XOR运算。
为此我用Java实现了一个MT19937伪随机数的工具类:
net.jmecn.mabi.utils.MersenneTwister.java
解码过程是这样的:
long seed = ((long) entry.Seed << 7) ^ 0xA9C36DE1L;
MersenneTwister mt = new MersenneTwister(seed);
for (int i = 0; i < data.length; i++) {
data[i] = (byte) (data[i] ^ mt.genrandInt32());
}
随后的解压缩过程就没有那么麻烦了。Java本身就有关于zlib算法的实现,因此直接把解码后的数据交给java.util.zip.Inflater
即可。
在这里我遇到了一个古怪的问题,我在Linux系统下调试这个项目的时候,发现所有dds图片都没法解压缩,而同一个文件在PC上就可以正常解压,真是够古怪的。
Pack包的解析类为:
net.jmecn.mabi.pack.PackFile.java
PackageHeader代表Pack包的文件头信息,PackageEntry代表Pack包中每一个资源文件的入口。
为了在jMonkeyEngine中能够方便查找pack中的资源文件,我又另写了一个插件类:net.jmecn.mabi.plugin.PackLocator.java。
这个类的作用是协助jME3的AssetManager来查找指定路径的资源位置。用法如下:
String pack = "/home/yan/Apps/Mabinogi/package/language.pack";
assetManager.registerLocator(pack, PackLocator.class);
通过这种方式,可以把多个pack文件注册到assetManager中。在实际查找pack中的资源时,就可以通过assetManager.locateAsset("gfx/char/human/female/female_framework.frm")
这种方式来查找pack包中的资源。
3D模型文件
Mabinogi的3D模型分别保存为3种格式:pmg、frm、ani。
frm文件
frm文件中保存了模型的骨骼信息,定义了由最大128根骨头组成的层次关系。每个骨头都有保留有自己的命名、位置等信息。
对应到jME3来说,一个frm文件可以解析为一个Skeleton对象。该Skeleton中保存有最多128个Bone对象,每个Bone保存骨头的名称、位移等信息。
pmg文件
pmg文件中保存了模型的网格、材质等信息。一个pmg文件中可能包含多个网格,每个网格对应一个材质。
对应到jME3来说,一个pmg可以转化为一个Node,其中的若干个网格数据可以转化为多个Geometry,每个Geometry对应一个Material。
除了模型的形状以外,pmg文件中还记录了每个网格和frm中每个骨骼的绑定关系,基本上每个网格只与一个骨骼绑定。这意味着pmg文件中能够实现基本的骨骼蒙皮,但是在两个相邻网格之间可能出现“撕裂”现象。
ani文件
ani文件中保存了模型的骨骼动画数据,每个ani文件只保存一个动画。如果一个模型有站立、坐下、攻击等多个动画,就会保存为多个ani文件。
我在MabiPE的pack模块中定义了ani、pmg、frm三种文件的数据结构,然后在core模块中定义了通过assetManager加载这三种文件的插件:
其后我又在core模块中定义了AssetFactory工具类,用于把这些插件注册到AssetManager中。并通过一定的代码把这些文件数据解析为jME3能够识别的Geometry、Node、Skeleton、Bone、Animation等对象。
// Mabinogi plugin
assetManager.registerLoader(FrmLoader.class, "frm");
assetManager.registerLoader(PmgLoader.class, "pmg");
assetManager.registerLoader(AniLoader.class, "ani");
最后在core模块的ModelState负责加载并显示这些模型。
线性变换
事实上,当我首次显示洛奇的模型时,并不是像上图那样正常的。模型的每个部件都“龟缩”在坐标系的原点。
我想,这倒也可以理解。也许建模师在制作这些部件的时候,每个部件都是在建模工具的“原点”创建出来的。等这些部件雕琢成形后,在摆放到适合的位置上。因此我推测,pmg文件中肯定得保存着这些部件的“位置”信息,否则游戏里的模型是怎么正常显示的?
随后我仔细检查了pmg文件中每个网格的数据,发现每个网格数据中都额外保存着2个可疑的4x4矩阵(Matrix4f)。我想它们中某一个应该跟部件的位置有关,但是我不知道该怎么处理这些数据。
我卡在这里了,内心非常煎熬。所以说,同学们,数学很重要啊!
我花了大概一周左右,重新学习了线新代数中的主要概念和计算方法;又花了两天在B站上看完了这套视频:线性代数的本质 - 系列合集。
最终我明白了,这些4x4矩阵的作用是对3D向量进行线性变换。它的原理是先把3维向量(x, y, z)提升到4维空间中,变成(x, y, z, 0),然后进行线性变换,最后再把结果的4维向量(x', y', z', 0)降维成3维向量(x', y', z')。
由于整个线性变换是在一个更高维度空间中进行的,因此,通过一次变换完成位移(translation)、旋转(rotation)、缩放(scale)三个操作。
看得有点头大是吧?不过实际应用时还好,jME3其实已经在Matrix4f中实现了一些与线性变换有关的方法,我只要试一下哪个4x4矩阵能把部件转换到正确的位置就可以了。
最终的代码就这么简单:
Geometry geom = new Geometry(pmg.meshName, mesh);
geom.setLocalRotation(pmg.majorMatrix.toRotationQuat());
geom.setLocalTranslation(pmg.majorMatrix.toTranslationVector());
geom.setLocalScale(pmg.majorMatrix.toScaleVector());
结果如下:
在经历了漫长的学习后,最终出现了让我满足的结果,说不自豪是骗人的。然而当我开始播放骨骼动画后,却看到了下面的画面:
何方妖孽!!
静态模型显示还挺正常,然而一播放动画模型就会变成畸形。从前面一个截图来看,骨骼是没有问题的,有问题的只能是动画数据了。
骨骼动画到底是怎么工作的?中间有哪些数学运算?为什么会出现这个结果?我又头大了,而且由于工作阅览越忙,于是这个项目就被我暂时搁置了。
动画数据
直到这个端午节,我终于有时间好好看看这个项目的问题。
正好前几天教程正写到骨骼动画这一节,于是我抽空仔细阅读了jME3中骨骼动画的源码。
首先,从模骨骼的姿态推断,应该是AnimControl没有正常工作。因为Skeleton的姿态是由AnimControl负责计算的。
为什么这个姿态这么古怪?是不是动画的数据不正常,还是jME3的动画系统和Mabinogi的动画系统不兼容?我看不到mabinogi的源码,不知道它的动画系统是如何工作的,但我可以阅读AnimControl的源码。
通过阅读AnimControl与其相关的代码,我发现最终负责根据骨骼动画数据计算Skeleton姿态的代码,位于BoneTrack类中。
/**
*
* Modify the bone which this track modifies in the skeleton to contain
* the correct animation transforms for a given time.
* The transforms can be interpolated in some method from the keyframes.
*
* @param time the current time of the animation
* @param weight the weight of the animation
* @param control
* @param channel
* @param vars
*/
public void setTime(float time, float weight, AnimControl control, AnimChannel channel, TempVars vars) {
BitSet affectedBones = channel.getAffectedBones();
if (affectedBones != null && !affectedBones.get(targetBoneIndex)) {
return;
}
Bone target = control.getSkeleton().getBone(targetBoneIndex);
Vector3f tempV = vars.vect1;
Vector3f tempS = vars.vect2;
Quaternion tempQ = vars.quat1;
Vector3f tempV2 = vars.vect3;
Vector3f tempS2 = vars.vect4;
Quaternion tempQ2 = vars.quat2;
int lastFrame = times.length - 1;
if (time < 0 || lastFrame == 0) {
rotations.get(0, tempQ);
translations.get(0, tempV);
if (scales != null) {
scales.get(0, tempS);
}
} else if (time >= times[lastFrame]) {
rotations.get(lastFrame, tempQ);
translations.get(lastFrame, tempV);
if (scales != null) {
scales.get(lastFrame, tempS);
}
} else {
int startFrame = 0;
int endFrame = 1;
// use lastFrame so we never overflow the array
int i;
for (i = 0; i < lastFrame && times[i] < time; i++) {
startFrame = i;
endFrame = i + 1;
}
float blend = (time - times[startFrame])
/ (times[endFrame] - times[startFrame]);
rotations.get(startFrame, tempQ);
translations.get(startFrame, tempV);
if (scales != null) {
scales.get(startFrame, tempS);
}
rotations.get(endFrame, tempQ2);
translations.get(endFrame, tempV2);
if (scales != null) {
scales.get(endFrame, tempS2);
}
tempQ.nlerp(tempQ2, blend);
tempV.interpolateLocal(tempV2, blend);
tempS.interpolateLocal(tempS2, blend);
}
target.blendAnimTransforms(tempV, tempQ, scales != null ? tempS : null, weight);
}
Bone中根据BoneTrack计算的数据来改变自身姿态的代码如下:
/**
* Blends the given animation transform onto the bone's local transform.
* <p>
* Subsequent calls of this method stack up, with the final transformation
* of the bone computed at {@link #updateModelTransforms() } which resets
* the stack.
* <p>
* E.g. a single transform blend with weight = 0.5 followed by an
* updateModelTransforms() call will result in final transform = transform * 0.5.
* Two transform blends with weight = 0.5 each will result in the two
* transforms blended together (nlerp) with blend = 0.5.
*
* @param translation The translation to blend in
* @param rotation The rotation to blend in
* @param scale The scale to blend in
* @param weight The weight of the transform to apply. Set to 1.0 to prevent
* any other transform from being applied until updateModelTransforms().
*/
void blendAnimTransforms(Vector3f translation, Quaternion rotation, Vector3f scale, float weight) {
if (userControl) {
return;
}
if (weight == 0) {
// Do not apply this transform at all.
return;
}
if (currentWeightSum == 1){
return; // More than 2 transforms are being blended
} else if (currentWeightSum == -1 || currentWeightSum == 0) {
// Set the transform fully
localPos.set(bindPos).addLocal(translation);
localRot.set(bindRot).multLocal(rotation);
if (scale != null) {
localScale.set(bindScale).multLocal(scale);
}
// Set the weight. It will be applied in updateModelTransforms().
currentWeightSum = weight;
} else {
// The weight is already set.
// Blend in the new transform.
TempVars vars = TempVars.get();
Vector3f tmpV = vars.vect1;
Vector3f tmpV2 = vars.vect2;
Quaternion tmpQ = vars.quat1;
tmpV.set(bindPos).addLocal(translation);
localPos.interpolateLocal(tmpV, weight);
tmpQ.set(bindRot).multLocal(rotation);
localRot.nlerp(tmpQ, weight);
if (scale != null) {
tmpV2.set(bindScale).multLocal(scale);
localScale.interpolateLocal(tmpV2, weight);
}
// Ensures no new weights will be blended in the future.
currentWeightSum = 1;
vars.release();
}
}
这两个方法中的代码不少,但是它们做的事情其实很简单。我尝试简单描述一下:假设Bone的初始坐标为Va
,BoneTrack中记录了Bone在不同时刻的位移数据V[n]
。在任意时刻time
,Bone的坐标为Vt = Va + V[t]
。
BoneTrack中setTime
方法的作用,就是根据动画播放的时间time
,查找具体的位移数据V[t]
。然后Bone的blendAnimTransforms
方法的作用,就是根据Bone的初始位移Va
和BoneTrack提供的位移数据V[t]
,计算出实际的位移Va + v[t]
。
下面就是Bone中计算当前位移的关键代码。
tmpV.set(bindPos).addLocal(translation);
localPos.interpolateLocal(tmpV, weight);
除了位移(Translation)以外,BoneTrack和Bone还要同时计算旋转(Rotation)、缩放(Scale)的数据,这就导致这两个方法的代码行数稍微有点多。
tmpQ.set(bindRot).multLocal(rotation);
localRot.nlerp(tmpQ, weight);
if (scale != null) {
tmpV2.set(bindScale).multLocal(scale);
localScale.interpolateLocal(tmpV2, weight);
}
根据上面的代码可以看出,位移的计算方式是加法,旋转和缩放的计算方式则是乘法。
jME3中骨骼动画的计算方式我差不多明白了,下面再来看看ani文件中的数据是否有问题。
数据确实有问题。
根据分析BoneTrack和Bone的源码,可知jME3期待的动画数据是相对的运动数据。假设骨骼的初始位移为Va
,相对位移为Vb
,最终位移为Vc
,有Va + Vb = Vc
。frm文件中存储了Va
,jME3期待ani文件中保存Vb
,然而ani文件中存储的是Vc
!
因此,如果想要在jME3中正确播放Mabinogi的动画,就需要对ani文件中的数据进行一些修正。基本方式,就是根据Vc和Va,推算出Vb = Vc - Va。
/**
* 根据骨骼姿态,修正动画数据。
*/
Vector3f bindPosition = new Vector3f();
Quaternion bindRotationI = new Quaternion();// 逆旋转
Quaternion tmpQ = new Quaternion();
for (int i = 0; i < aniFile.boneCount; i++) {
AniTrack aniTrack = aniFile.aniTracks[i];
// ...
// 骨骼的初始动作
if (skeleton != null) {
Bone bone = skeleton.getBone(i);
bindPosition.set(bone.getBindPosition());
bindRotationI.set(bone.getBindRotation().inverse());// 计算逆旋转
}
BoneTrack track = new BoneTrack(i);
anim.addTrack(track);
float[] times = new float[aniTrack.frameCount];
Vector3f[] translations = new Vector3f[aniTrack.frameCount];
Quaternion[] rotations = new Quaternion[aniTrack.frameCount];
Vector3f[] scales = new Vector3f[aniTrack.frameCount];
for (int j = 0; j < aniTrack.frameCount; j++) {
AniFrame aniFrame = aniTrack.aniFrames[j];
times[j] = (float) aniFrame.frameNo / aniFile.framePerSecond;
translations[j] = new Vector3f(aniFrame.x, aniFrame.y, aniFrame.z);
rotations[j] = new Quaternion(aniFrame.qx, aniFrame.qy, aniFrame.qz, -aniFrame.qw);
scales[j] = new Vector3f(1f, 1f, 1f);
if (skeleton != null) {
// 还原位移
translations[j].subtractLocal(bindPosition);
// 还原旋转
bindRotationI.mult(rotations[j], tmpQ);
rotations[j].set(tmpQ);
}
}
track.setKeyframes(times, translations, rotations, scales);
}
经过一些逆运算后,动画数据终于正常了。
不过,模型似乎被肢解了?我怀疑这是骨骼蒙皮的错。因为在pmg文件中,网格的数据并不包含每个顶点和每个骨骼的权重关系,而是整个网格和一个骨骼有关联。
出现上述画面,怎么想都是这种粗糙的骨骼蒙皮不对!
然而,我不知道该怎么调整骨骼蒙皮数据,因为pmg中提供的数据信息缺失太少,缺乏每个顶点和骨骼的对应关系。
骨骼蒙皮
从画面中可以看到,红线所代表的Skeleton姿态是正常的,这说明AnimControl已经能够正常工作。然而模型确实被“肢解”了,这就说明是SkeletonControl没有正常工作。
于是我又仔仔细细阅读了SkeletonControl的源码,法线最终计算骨骼蒙皮的线性变换数据,是由Bone类中一个名为getOffsetTransform
的方法生成的。
/**
* Stores the skinning transform in the specified Matrix4f.
* The skinning transform applies the animation of the bone to a vertex.
*
* This assumes that the world transforms for the entire bone hierarchy
* have already been computed, otherwise this method will return undefined
* results.
*
* @param outTransform
*/
void getOffsetTransform(Matrix4f outTransform, Quaternion tmp1, Vector3f tmp2, Vector3f tmp3, Matrix3f tmp4) {
// Computing scale
Vector3f scale = modelScale.mult(modelBindInverseScale, tmp3);
// Computing rotation
Quaternion rotate = modelRot.mult(modelBindInverseRot, tmp1);
// Computing translation
// Translation depend on rotation and scale
Vector3f translate = modelPos.add(rotate.mult(scale.mult(modelBindInversePos, tmp2), tmp2), tmp2);
// Populating the matrix
outTransform.setTransform(translate, scale, rotate.toRotationMatrix(tmp4));
}
这个方法的作用,是根据骨骼中记录的线性变换数据,计算一个逆矩阵。这样就可以使用一个线性变化,先把模型中的顶点复原到骨骼的“原点”处,再使用新的骨骼数据计算出新的位置。
问题出在哪里?
问题在于,骨骼中的线性变换,是根据骨骼相对世界坐标系的原点来计算的。然而在我的代码中,每个Geometry中的网格,使用的是相对每个Geometry原点的线性变换。世界坐标的线性变化被应用到Geometry上了,没有应用到Mesh上。
这导致骨骼动画在进行计算时,Geometry中的顶点数据先被转移到了一个错误的原点上,最终根据动画数据计算的位置也不对。
想明白之后,我先取消了Geometry的线性变换。
Geometry geom = new Geometry(pmg.meshName, mesh);
/**
* 该空间变化应该应用到网格顶点上,否则骨骼蒙皮动画无法正常工作。
*/
//geom.setLocalRotation(pmg.majorMatrix.toRotationQuat());
//geom.setLocalTranslation(pmg.majorMatrix.toTranslationVector());
//geom.setLocalScale(pmg.majorMatrix.toScaleVector());
然后对网格中每一个顶点的位置应用了这个线性变换,并把法线旋转到变换后的方向。
/**
* 创建网格
*
* @param pmg
* @return
*/
public static Mesh buildMesh(PmGeometry pmg) {
Mesh mesh = new Mesh();
// ...
Matrix4f transform = pmg.majorMatrix;
Matrix3f rotation = transform.toRotationMatrix();
for (int i = 0; i < pmg.vertexCount; i++) {
/**
* 为了保证骨骼蒙皮动画正常,顶点和法线要通过pmg.majorMatrix进行空间转换。
*/
vertexes[i] = transform.mult(pmg.verts[i].getPosition());
normals[i] = rotation.mult(pmg.verts[i].getNormal());
//vertexes[i] = pmg.verts[i].getPosition();
//normals[i] = pmg.verts[i].getNormal();
vertexColors[i] = pmg.verts[i].getColor();
texCoords[i] = pmg.verts[i].getTexCoord();
}
//...
return mesh;
}
当Mesh中每个顶点的坐标都被转移到世界坐标系后,骨骼动画终于正常工作了。
我感觉自己得到了升华。
模型撕裂
从上图可以看出,这只小狐狸的尾巴模型是撕裂的。这个问题的原因现在很明确,就是骨骼蒙皮不正常。部件的连接处本应受到至少2个骨骼的影响,但是现在只受到了一个骨骼的影响,而且权重为1。
我现在也想解决这个问题,但是pmg文件中的数据缺失对顶点权重这里描述得不是很清楚。所以只能暂时搁置这个问题,等以后我对pmg文件的数据结构更清楚之后再解决了。
精灵和人类的原点不对
人类种族和精灵种族使用同一套骨骼,它们的模型也有同样的bug。
从上图可以看到,与一般的模型不同,这个精灵模型的原点不在脚下,而是在身体的中心。根据SkeletonControl的工作原理可以推测,这种模型在播放骨骼动画时肯定会被“肢解”。
确实被“肢解”了。
这个问题太奇怪了,为什么只有人类和精灵的模型被下沉了?我可以通过手动调整的方式把它们上移,但我认为pack包中可能有某个文件存储着这些上移的数据。所以这个问题暂时搁置吧。