MabiPE开发日志

这个端午节我没有继续写教程,而是捡起了一个被我暂停了的兴趣项目:洛奇口袋版

之所以暂停这个项目,主要是因为开发过程中遇到了一些古怪的问题,无论我怎么调试,都搞不清楚错在哪里。

  1. 播放骨骼动画时,角色模型被肢解了。
  2. 我希望通过手动拖拽进度条来控制动画,但是我不知道该怎么实现。
  3. 模型的影子会突然消失。
  4. 角色模型的部件之间有裂缝。
  5. 人类/精灵的模型原点在肚脐处,不在脚底。这导致骨骼动画绑定异常。

经过几个月的学习之后,在这个端午节总算是解决了前2个问题,其他问题我也有信心解决了。

项目介绍

首先我还是要介绍一下这个项目。

“洛奇(Mabinogi)”是一款韩国NEXON公司出品的生活类3DMMORPG,我从大学开始玩这个游戏,至今断断续续也有好些年了。

Mabinogi

洛奇口袋版(MabiPocketEdition)是我在学习jMonkeyEngine之后,一时兴起构思的项目。最初我只想把洛奇中的游戏场景、角色模型搬到手机上,实现一个单机版的纸娃娃系统,这样我就可以在手机上捏人玩。

后来我想,不如把服装染色、离线查看广告板、每日任务提示等功能也做进去,这样做成一个手机App,或许可以方便洛奇的玩家。

不过想归想,首先要能够实现一个纸娃娃系统才行,否则其他都是虚的。目前我做成的系统是这样的。

nao_rua

技术难点

Pack文件解析

Mabinogi的大部分游戏资源是存储在.pack包中的,其中的文件数据经过了加密、压缩。

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文件中能够实现基本的骨骼蒙皮,但是在两个相邻网格之间可能出现“撕裂”现象。

pmg和frm

ani文件

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负责加载并显示这些模型。

线性变换

事实上,当我首次显示洛奇的模型时,并不是像上图那样正常的。模型的每个部件都“龟缩”在坐标系的原点。

all in center

我想,这倒也可以理解。也许建模师在制作这些部件的时候,每个部件都是在建模工具的“原点”创建出来的。等这些部件雕琢成形后,在摆放到适合的位置上。因此我推测,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包中可能有某个文件存储着这些上移的数据。所以这个问题暂时搁置吧。