一、JME3支持的动画类型
JME3目前只支持骨骼动画和节点动画。虽然它貌似也曾经实现过关键帧动画(有一个PoseTrack类),但是现在废弃了。JME3最重视的还是骨骼动画,看看animation包下面专门定义了Skeleton、Bone、BoneTrack等那么多类就可想而知。
二、骨骼动画相关知识
由于我以前从来没有学习过3D动画知识,所以有一些基本的数学原理不太懂,最近也差了一些资料。
下面几个帖子非常有学习价值:
骨骼蒙皮动画(SkinnedMesh)的原理解析(一)
骨骼蒙皮动画(Skinned Mesh)的原理解析(二)
Skinned Mesh原理解析和一个最简单的实现示例
三、JME3动画接口
JME3把动画的数据、控制进行了抽象,希望能够通过统一的方式来管理各种不同类型的动画。他的动画接口包含这么几个方面:
(1)骨骼数据
骨骼的数据主要包括每块Bone的定义以及Bone之间的继承关系,按树形结构组织,最终形成一个完整的Skeleton。
JME3中的Bone其实应该叫做Joint(关节)。按我的理解,关节之间那根线才叫骨骼,骨骼相连的地方不是关节又是什么呢?不过骨骼动画中最精髓的部分就是关节点的移动,那么重点关注这个节点也对。
每个Bone都有它自己的名称、唯一的父节点以及0~N个子节点。由于Bone其实是关节点,因此它还有自己的Position、Rotation以及Scale。忽略掉Bone类中的绝大部分代码,它的基本属性是这样的:
/** *Bone
describes a bone in the bone-weight skeletal animation * system. A bone contains a name and an index, as well as relevant * transformation data. * * @author Kirill Vainer */ public final class Bone implements Savable { private String name; private Bone parent; private final ArrayListchildren = new ArrayList (); /** * Initial transform is the local bind transform of this bone. * PARENT SPACE -> BONE SPACE */ private Vector3f initialPos; private Quaternion initialRot; private Vector3f initialScale; /** * The inverse world bind transform. * BONE SPACE -> MODEL SPACE */ private Vector3f worldBindInversePos; private Quaternion worldBindInverseRot; private Vector3f worldBindInverseScale; /** * The local animated transform combined with the local bind transform and parent world transform */ private Vector3f localPos = new Vector3f(); private Quaternion localRot = new Quaternion(); private Vector3f localScale = new Vector3f(1.0f, 1.0f, 1.0f); /** * MODEL SPACE -> BONE SPACE (in animated state) */ private Vector3f worldPos = new Vector3f(); private Quaternion worldRot = new Quaternion(); private Vector3f worldScale = new Vector3f(); // Used for getCombinedTransform private Transform tmpTransform = new Transform(); /** * Creates a new bone with the given name. * * @param name Name to give to this bone */ public Bone(String name) { if (name == null) throw new IllegalArgumentException("Name cannot be null"); this.name = name; initialPos = new Vector3f(); initialRot = new Quaternion(); initialScale = new Vector3f(1, 1, 1); worldBindInversePos = new Vector3f(); worldBindInverseRot = new Quaternion(); worldBindInverseScale = new Vector3f(); } /** * Returns parent bone of this bone, or null if it is a root bone. * @return The parent bone of this bone, or null if it is a root bone. */ public Bone getParent() { return parent; } /** * Add a new child to this bone. Shouldn't be used by user code. * Can corrupt skeleton. * * @param bone The bone to add */ public void addChild(Bone bone) { children.add(bone); bone.parent = this; } /** * Sets local bind transform for bone. * Call setBindingPose() after all of the skeleton bones' bind transforms are set to save them. */ public void setBindTransforms(Vector3f translation, Quaternion rotation, Vector3f scale) { initialPos.set(translation); initialRot.set(rotation); //ogre.xml can have null scale values breaking this if the check is removed if (scale != null) { initialScale.set(scale); } localPos.set(translation); localRot.set(rotation); if (scale != null) { localScale.set(scale); } } }
我们很容易就可以new一个骨骼,然后通过addChild(Bone)方法来给他添加子节点,并通过setBindTransforms()方法来设置骨骼的初始位置。
除此之外,最重要的就是骨骼中的父节点是怎么计算子节点的空间变换了。Bone提供了update()方法来更新根节点以及所有子节点的空间变换。
顺带一提,update中使用的是先序遍历,保证先更新父节点,再更新子节点。
/** * Updates the world transforms for this bone, and, possibly the attach node * if not null. ** The world transform of this bone is computed by combining the parent's * world transform with this bones' local transform. */ public final void updateWorldVectors() { if (currentWeightSum == 1f) { currentWeightSum = -1; } else if (currentWeightSum != -1f) { // Apply the weight to the local transform if (currentWeightSum == 0) { localRot.set(initialRot); localPos.set(initialPos); localScale.set(initialScale); } else { float invWeightSum = 1f - currentWeightSum; localRot.nlerp(initialRot, invWeightSum); localPos.interpolate(initialPos, invWeightSum); localScale.interpolate(initialScale, invWeightSum); } // Future invocations of transform blend will start over. currentWeightSum = -1; } if (parent != null) { //rotation parent.worldRot.mult(localRot, worldRot); //scale //For scale parent scale is not taken into account! // worldScale.set(localScale); parent.worldScale.mult(localScale, worldScale); //translation //scale and rotation of parent affect bone position parent.worldRot.mult(localPos, worldPos); worldPos.multLocal(parent.worldScale); worldPos.addLocal(parent.worldPos); } else { worldRot.set(localRot); worldPos.set(localPos); worldScale.set(localScale); } if (attachNode != null) { attachNode.setLocalTranslation(worldPos); attachNode.setLocalRotation(worldRot); attachNode.setLocalScale(worldScale); } } /** * Updates world transforms for this bone and it's children. */ final void update() { this.updateWorldVectors(); for (int i = children.size() - 1; i >= 0; i--) { children.get(i).update(); } }
既然主要的工作都在Bone里面做了,那么还要Skeleton干什么呢?Skeleton主要用于管理所有的Bone,除此之外别无它用。
/** *Skeleton
is a convenience class for managing a bone hierarchy. * Skeleton updates the world transforms to reflect the current local * animated matrixes. * * @author Kirill Vainer */ public final class Skeleton implements Savable { private Bone[] rootBones; private Bone[] boneList; /** * Contains the skinning matrices, multiplying it by a vertex effected by a bone * will cause it to go to the animated position. */ private transient Matrix4f[] skinningMatrixes; /** * Creates a skeleton from a bone list. * The root bones are found automatically. ** Note that using this constructor will cause the bones in the list * to have their bind pose recomputed based on their local transforms. * * @param boneList The list of bones to manage by this Skeleton */ public Skeleton(Bone[] boneList) { this.boneList = boneList; List
rootBoneList = new ArrayList (); for (int i = boneList.length - 1; i >= 0; i--) { Bone b = boneList[i]; if (b.getParent() == null) { rootBoneList.add(b); } } rootBones = rootBoneList.toArray(new Bone[rootBoneList.size()]); createSkinningMatrices(); for (int i = rootBones.length - 1; i >= 0; i--) { Bone rootBone = rootBones[i]; rootBone.update(); rootBone.setBindingPose(); } }
Skeleton中也提供了不少用于计算空间变换的方法,主要都是按调用Bone中的方法,比如下面这个:
/** * Updates world transforms for all bones in this skeleton. * Typically called after setting local animation transforms. */ public void updateWorldVectors() { for (int i = rootBones.length - 1; i >= 0; i--) { rootBones[i].update(); } }
(2)动画数据
骨骼数据定义了骨架的形状以及空间变换算法。那么每一帧动画如何变换,就属于动画数据要考虑的问题了。
JME3为动画数据提供了2个接口:Track、Animation。
Track负责保存N个动画帧,Animation则由多个Track来组成。例如:
有一个飞机起飞的动画,这个动画有2个Track:第1个Track保存了飞机收起起落架的动作;第2个Track保存了飞机发动机启动的动画。每个Track都保存了一系列的关键帧数据。
Track只是一个接口,它只定义了3个最基本的方法,连动画关键帧的存储数据结构都没有定义。Track的实现类需要自己去实现。
public interface Track extends Savable, Cloneable { /** * Sets the time of the animation. * * Internally, the track will retrieve objects from the control * and modify them according to the properties of the channel and the * given parameters. * * @param time The time in the animation * @param weight The weight from 0 to 1 on how much to apply the track * @param control The control which the track should effect * @param channel The channel which the track should effect */ public void setTime(float time, float weight, AnimControl control, AnimChannel channel, TempVars vars); /** * @return the length of the track */ public float getLength(); /** * This method creates a clone of the current object. * @return a clone of the current object */ public Track clone(); }
这3个方法中,最重要的就是setTime方法。它用于计算在指定时刻的关键帧数据,其实现类一般都会使用插值法来进行计算。
顺便说一下,JME3中的动画时间是以“秒”为单位的,getLength()方法要返回动画的实际时长,setTime中的time参数也要是实际时间。比如这个Track的总时长为2.5秒,现在想播放第0.8秒的动画,那么setTime中的time参数就是0.8。
Track最重要的实现类就是BoneTrack和SpatialTrack,当然我们也可以根据自己的需要来实现Track接口。
/** * Contains a list of transforms and times for each keyframe. * * @author Kirill Vainer */ public final class BoneTrack implements Track { /** * Bone index in the skeleton which this track effects. */ private int targetBoneIndex; /** * Transforms and times for track. */ private CompactVector3Array translations; private CompactQuaternionArray rotations; private CompactVector3Array scales; private float[] times; /** * Creates a bone track for the given bone index * @param targetBoneIndex the bone index * @param times a float array with the time of each frame * @param translations the translation of the bone for each frame * @param rotations the rotation of the bone for each frame */ public BoneTrack(int targetBoneIndex, float[] times, Vector3f[] translations, Quaternion[] rotations) { this.targetBoneIndex = targetBoneIndex; this.setKeyframes(times, translations, rotations); } /** * Creates a bone track for the given bone index * @param targetBoneIndex the bone index * @param times a float array with the time of each frame * @param translations the translation of the bone for each frame * @param rotations the rotation of the bone for each frame * @param scales the scale of the bone for each frame */ public BoneTrack(int targetBoneIndex, float[] times, Vector3f[] translations, Quaternion[] rotations, Vector3f[] scales) { this.targetBoneIndex = targetBoneIndex; this.setKeyframes(times, translations, rotations, scales); } /** * Set the translations and rotations for this bone track * @param times a float array with the time of each frame * @param translations the translation of the bone for each frame * @param rotations the rotation of the bone for each frame */ public void setKeyframes(float[] times, Vector3f[] translations, Quaternion[] rotations) { if (times.length == 0) { throw new RuntimeException("BoneTrack with no keyframes!"); } assert times.length == translations.length && times.length == rotations.length; this.times = times; this.translations = new CompactVector3Array(); this.translations.add(translations); this.translations.freeze(); this.rotations = new CompactQuaternionArray(); this.rotations.add(rotations); this.rotations.freeze(); } /** * Set the translations, rotations and scales for this bone track * @param times a float array with the time of each frame * @param translations the translation of the bone for each frame * @param rotations the rotation of the bone for each frame * @param scales the scale of the bone for each frame */ public void setKeyframes(float[] times, Vector3f[] translations, Quaternion[] rotations, Vector3f[] scales) { this.setKeyframes(times, translations, rotations); assert times.length == scales.length; if (scales != null) { this.scales = new CompactVector3Array(); this.scales.add(scales); this.scales.freeze(); } } /** * * 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.interpolate(tempV2, blend); tempS.interpolate(tempS2, blend); } target.blendAnimTransforms(tempV, tempQ, scales != null ? tempS : } /** * @return the length of the track */ public float getLength() { return times == null ? 0 : times[times.length - 1] - times[0]; }
Animation就单纯了,主要用于管理Track。每个Animation可以有一个名字,比如"Walk"、"Idle"、"Attack"之类的,这样我们要播放动画的时候就很容易控制了。
Animation也有一个setTime方法,其实就是在调用所有Track的setTime。
/** * This method sets the current time of the animation. * This method behaves differently for every known track type. * Override this method if you have your own type of track. * * @param time the time of the animation * @param blendAmount the blend amount factor * @param control the animation control * @param channel the animation channel */ void setTime(float time, float blendAmount, AnimControl control, AnimChannel channel, TempVars vars) { if (tracks == null) { return; } for (Track track : tracks) { track.setTime(time, blendAmount, control, channel, vars); } }
(3)动画控制
JME3通过AnimControl和AnimChannel来控制动画的播放,加载一个带有骨骼动画数据的模型后,可以通过这俩接口来播放动画。具体的内容在wiki上有介绍,这里就不再赘述了。
http://wiki.jmonkeyengine.org/doku.php/jme3:beginner:hello_animation