第十二章:动画
本章我们将学习如何在jME3中播放3D动画。
概述
3D动画一般使用Blender、Maya、3DS Max、ZBrush等专业工具制作。jME3不能用于制作3D动画,但它可以导入包含3D动画数据的模型,然后在游戏中播放。
根据游戏开发的一般需求,jME3.1目前支持3种动画:
- 骨骼动画(Skeleton Animation)
- 骨骼动画用于制作动画角色,可以表演出角色的各种行为,例如“行走”、“攻击”、“跳跃”、“打招呼”等。
- 播放骨骼动画时,模型一般都是在原地不动的。例如单独播放“行走”动画,看起来更像是在走“太空步”。
- 骨骼动画的制作非常困难,需要专业人士付出大量精力和时间。
- 运动路径(Motion Path)
- 运动路径的作用是让模型按一定的轨迹移动。
- 一条路径上可以有多个路径点(way point)。
- 路径点之间的连线可以是直线,也可以是平滑的曲线。
- 运动路径是由程序生成的,不需要专业建模工具。
- 电影化(Cinematic)
- jME3的“电影化”工具是用来制作游戏中的剧情动画的。
- 你可以使用“电影化”工具来控制时间线,把骨骼动画、运动路径和声音结合起来做成关键帧,几个关键帧就可以表演出复杂的故事剧情。
- 除了角色模型以外,你还可以使用“电影化”工具来控制摄像机。
- 可以同时操纵多条时间线、多个角色的剧情动画。
- 剧情动画是由代码生成的,不需要专业的建模工具。
- 你需要像个编剧一样先设计好剧情脚本,然后再用代码实现它。
骨骼蒙皮动画
骨骼蒙皮动画,简称骨骼动画,因其占用磁盘空间少并且动画效果好被广泛用于3D游戏中。它把网格顶点(皮)绑定到一个骨骼层次上面,当骨骼层次变化之后,可以根据绑定信息计算出新的网格顶点坐标,进而驱动该网格变形。
一个完整的骨骼动画一般由骨架层次(Bone Hierarchy)、绑定网格(Rigged and Skinned mesh)以及一系列关键帧(Key Pose)组成,一个关键帧对应于骨架的一个新状态,两个关键帧之间的状态可以通过插值得到。
jME3游戏引擎只能加载、播放录制好的动画。因此,你必须使用其他工具(比如Blender)创建角色动画。
究竟什么样的模型才算是一个可以播放的模型呢?
- 绑定骨骼(Skeleton Rigging): 必须为模型构造骨架。
- 蒙皮(Skinning): 把单个骨头与相应的皮肤区域关联起来。
- 关键帧动画: 一幅关键帧就是某个动作序列的快照记录。
你有3种途径获得制作好的动画模型:
- 从网上下载免费的模型。
- 从其他动画制作师哪里购买做好的模型。
- 自己使用专业工具(Blender)制作。
3D骨骼动画的制作过程非常复杂,下面我会介绍制作的过程,但是不会涉及到具体的方法(因为我也不太会)。
讲解的过程中,我将会请jME3社区的吉祥物Jaime(吉米)来做我的模特。
Jaime是jME3核心开发者
Nehon
使用Blender制作的3D动画模型,它现在是jme3-testdata模块中的一部分。模型的加载路径为:Models/Jaime/Jaime.j3o。
Jaime的原始文件为blender格式,保存在googlecode.com上。我在github上保留了一个备份文件,下载:Jaime.blend(9.5MB)。
设计
在开始做动画之前,首先要进行设计。据说专业制作3D动画时,制作人员会考虑剧本、人设、场景、分镜之类的东西。我们做的是游戏,主要根据游戏的需要来设计角色的外形、姿态、动作等等。
Jaime的动作包括:
- Idle 站在原地
- Wave 挥手打招呼
- Walk 用双足行走
- Run 四肢着地跑
- Punches 像正前方连续出拳
- SideKick (左)侧踢
- JumpStart、Jumping、JumpEnd 跳跃(这个动作被分解成了起跳、滞空、落地3个部分)
- Taunt 嘲讽(双拳捶胸,作咆哮状)
建模
建模这个就不多说了,它的结果就是我们常见的静态模型。
为了绑定骨骼动画方便,一般人形的模型都会做成一个标准的大字:双臂张开、双腿站直、眼睛目视前方。
绑定骨骼(Rigging)
想让静态模型动起来,关键就靠绑定骨骼。首先要制作骨骼,然后要把骨骼和模型的网格绑定,后一步也称为蒙皮。
制作骨骼
这个步骤的重点是根据模型的形状来制作骨骼,让骨骼的初始位置和模型的各个位置保持一致。
在不同的建模工具中,骨骼有不同的表现形式,诸如:
注意!!骨骼动画中的“骨骼”,并不是你在上图中看到的锥体、方块或者线条,而是各个连接处的“关节点”!!这些关节点以父子层次关系链接在一起,最能表现骨骼本质是上图中的“线框”形态。
骨骼应当遵循命名规则,这样是不仅是为了方便3D引擎区别哪是哪,后面在蒙皮时也会用到。
骨头之间的连接采用父子层次结构(树形结构):移动一块骨头会拉着别的骨头跟它一起动(比如运动胳膊会带动手掌)。
下图为Jaime全身骨骼的名字和层次结构:
模型的动作越复杂,需要的骨骼越多。以Jaime为例,为了让Jaime做出各种面部表情,它的头部多很多额外的骨骼是用来控制眉毛、眼睛、嘴巴的。
为了游戏效率考虑,应该尽量减少骨骼个数量。jME3支持最多128根骨头。
蒙皮(Skinning)
蒙皮中的“皮肤”指的是模型的网格。刚做好的骨骼和网格是分离的,蒙皮就是将骨骼和网格绑定的过程。
每块骨头都与部分皮肤相连。动画制作的时候,(看不见的)骨头拽着(看得见的)皮肤跟它一起动。例子:大腿骨与大腿上部皮肤的连接。
每个骨头只能控制皮肤中的一部分,一块皮肤可能受不止一块骨头的影响。例如:膝盖、肘部。jME3支持每个顶点最多受4个骨头影响。每个骨头对同一位置的影响称为“权重”。
骨头与皮肤的连接过程是渐进的:你先制定每块皮肤受骨头移动影响的权重。比如:当大腿动作的时候,整条腿全部受影响,髋关节受的影响小一些,而头部完全不受影响。
例如,Jaime的膝关节主要影响膝盖的转向,同时也少量影响大腿的运动。
动画制作师需要使用一些蒙皮工具来设定每一块骨头对表皮的影响,也称为“刷权重”。
除了手绘权重以外,一些插件、工具也是必要的。例如Blender中的“封套”工具可以显示骨骼的影响范围。
动作制作
绑定骨骼之后,模型就可以动起来了。
Jaime,给大家挥个手。
关键帧
每个动画是由多个关键帧(Key Pose或Key Frame)组成的,而每个关键帧其实就是一个具体的姿势(Pose),例如“挥手(Wave)”动作就是由35个关键帧组成的。
在游戏引擎中,通过插值计算可以得到关键帧之间任意时刻的姿态,这样就能够平滑地播放动画。
动作制作方法
使用真人演员进行动作捕捉,可以获得最为生动的动作。不过这么做成本也很高,光是动作捕捉设备的价格就相当不菲。
直接用建模工具手动调整,这要求专业、经验、耐心、细心缺一不可。当然,也不少不了一些辅助工具。例如:
- 改变骨骼的外形,以便于制作者控制骨骼的姿态;
- 使用反向运动(IK,Inverse Kinematics),通过改变末端骨骼的位置来牵动整体姿态。
使用IK来编辑动画时,一般会在原骨骼的基础上增加一些辅助骨骼。例如Jaime目光的焦点、尾巴的尖端、手指、脚趾等。这些骨骼不和网格绑定,仅用于调整骨骼的姿态。
动画分轨
每个模型可以有多个动画。每个动画都有一个名字,用于辨识(例如:“走”,“攻击”,“跳”)。
在建模工具中制作动画时,可以将一长段动画分解成若干个子动画,这样在游戏中就可以分别引用。通过程序控制动画的播放顺序,就可以组合出新的动画。
合成
动画制作的后期,一般要将角色和场景、天空、灯光等合成,然后渲染成3D动画。
对于3D游戏来说,合成的过程将在游戏引擎中完成,模型会在场景图中实时渲染。为了将模型加入到游戏中,就需要将制作好的模型导出为包含动画数据的模型文件。
jME3.1目前支持obj、blender、orge、fbx格式的3D模型,其中orge和fbx是含动画数据的。jME3建议的制作过程为:
- 使用Blender或3ds Max等工具建立动画模型。
- 使用Orge插件,将模型导出为orge格式。
- 在jME3 SDK中导入orge模型,然后转成j3o文件。
- 在游戏中加载j3o模型文件。
附录:
Unity3D引擎有两篇文章,同样具有相当的参考价值:
其他文章:
播放动画
我们已经了解了什么是骨骼动画,下面介绍如何导入动画模型,并使用AnimControl和AnimChannel来播放骨骼动画。
样例代码中使用的模型依然是Jaime。程序启动后Jaime将会站着不动;按住键盘上的w键Jaime就会开始行走;按空格键Jaime会跳起来。
样例代码
package net.jmecn;
import com.jme3.animation.AnimChannel;
import com.jme3.animation.AnimControl;
import com.jme3.animation.AnimEventListener;
import com.jme3.animation.LoopMode;
import com.jme3.app.SimpleApplication;
import com.jme3.input.KeyInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.scene.Geometry;
import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Quad;
/**
* 动画
*
* @author yanmaoyuan
*
*/
public class HelloAnimation extends SimpleApplication {
/**
* 按W键行走
*/
private final static String WALK = "walk";
/**
* 按空格键跳跃
*/
private final static String JUMP = "jump";
/**
* 记录Jaime的行走状态。
*/
private boolean isWalking = false;
/**
* 动画模型
*/
private Spatial spatial;
private AnimControl animControl;
private AnimChannel animChannel;
public static void main(String[] args) {
// 启动程序
HelloAnimation app = new HelloAnimation();
app.start();
}
@Override
public void simpleInitApp() {
// 初始化摄像机
initCamera();
// 初始化灯光
initLight();
// 初始化按键输入
initKeys();
// 初始化场景
initScene();
// 动画控制器
animControl = spatial.getControl(AnimControl.class);
animControl.addListener(animEventListener);
// 显示这个模型中有多少个动画,每个动画的名字是什么。
System.out.println(animControl.getAnimationNames());
animChannel = animControl.createChannel();
// 播放“闲置”动画
animChannel.setAnim("Idle");
}
/**
* 初始化摄像机
*/
private void initCamera() {
// 禁用第一人称摄像机
flyCam.setEnabled(false);
cam.setLocation(new Vector3f(1, 2, 3));
cam.lookAt(new Vector3f(0, 0.5f, 0), new Vector3f(0, 1, 0));
}
/**
* 初始化光源
*/
private void initLight() {
// 定向光
DirectionalLight sunLight = new DirectionalLight();
sunLight.setDirection(new Vector3f(-1, -2, -3));
sunLight.setColor(new ColorRGBA(0.8f, 0.8f, 0.8f, 1f));
// 环境光
AmbientLight ambientLight = new AmbientLight();
ambientLight.setColor(new ColorRGBA(0.2f, 0.2f, 0.2f, 1f));
// 将光源添加到场景图中
rootNode.addLight(sunLight);
rootNode.addLight(ambientLight);
}
/**
* 初始化按键
*/
private void initKeys() {
// 按W键行走
inputManager.addMapping(WALK, new KeyTrigger(KeyInput.KEY_W));
// 按空格键跳跃
inputManager.addMapping(JUMP, new KeyTrigger(KeyInput.KEY_SPACE));
inputManager.addListener(actionListener, WALK, JUMP);
}
/**
* 初始化场景
*/
private void initScene() {
// 加载Jaime模型
spatial = assetManager.loadModel("Models/Jaime/Jaime.j3o");
rootNode.attachChild(spatial);
// 创建一个平面作为舞台
Geometry stage = new Geometry("Stage", new Quad(2, 2));
Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
mat.setColor("Diffuse", ColorRGBA.White);
mat.setColor("Specular", ColorRGBA.White);
mat.setColor("Ambient", ColorRGBA.Black);
mat.setFloat("Shininess", 0);
mat.setBoolean("UseMaterialColors", true);
stage.setMaterial(mat);
stage.rotate(-FastMath.HALF_PI, 0, 0);
stage.center();
rootNode.attachChild(stage);
}
/**
* 按键动作监听器
*/
private ActionListener actionListener = new ActionListener() {
@Override
public void onAction(String name, boolean isPressed, float tpf) {
/**
* 若Jaime已经处于JumpStart/Jumping/JumpEnd状态,就不要再做其他动作了。
*/
// 查询当前正在播放的动画
String curAnim = animChannel.getAnimationName();
if (curAnim != null && curAnim.startsWith("Jump")) {
return;
}
if (WALK.equals(name)) {// 走
// 记录行走状态
isWalking = isPressed;
if (isPressed) {
// 播放“行走”动画
animChannel.setAnim("Walk");
animChannel.setLoopMode(LoopMode.Loop);// 循环播放
} else {
// 播放“闲置”动画
animChannel.setAnim("Idle");
animChannel.setLoopMode(LoopMode.Loop);
}
} else if (JUMP.equals(name)) {// 跳
if (isPressed) {
// 播放“起跳”动画
animChannel.setAnim("JumpStart");
animChannel.setLoopMode(LoopMode.DontLoop);
animChannel.setSpeed(1.5f);
}
}
}
};
/**
* 动画事件监听器
*/
private AnimEventListener animEventListener = new AnimEventListener() {
@Override
public void onAnimCycleDone(AnimControl control, AnimChannel channel, String animName) {
if ("JumpStart".equals(animName)) {
// “起跳”动作结束后,紧接着播放“着地”动画。
channel.setAnim("JumpEnd");
channel.setLoopMode(LoopMode.DontLoop);
channel.setSpeed(1.5f);
} else if ("JumpEnd".equals(animName)) {
// “着地”后,根据按键状态来播放“行走”或“闲置”动画。
if (isWalking) {
channel.setAnim("Walk");
channel.setLoopMode(LoopMode.Loop);
} else {
channel.setAnim("Idle");
channel.setLoopMode(LoopMode.Loop);
}
}
}
@Override
public void onAnimChange(AnimControl control, AnimChannel channel, String animName) {
}
};
}
加载动画模型
这个步骤比较简单,使用assetManager直接加载Jaime.j3o模型即可。
// 加载Jaime模型
spatial = assetManager.loadModel("Models/Jaime/Jaime.j3o");
rootNode.attachChild(spatial);
需要注意的是,Jaime采用了感光材质,因此场景中必须要添加光源才能看到Jaime。
/**
* 初始化光源
*/
private void initLight() {
// 定向光
DirectionalLight sunLight = new DirectionalLight();
sunLight.setDirection(new Vector3f(-1, -2, -3));
sunLight.setColor(new ColorRGBA(0.8f, 0.8f, 0.8f, 1f));
// 环境光
AmbientLight ambientLight = new AmbientLight();
ambientLight.setColor(new ColorRGBA(0.2f, 0.2f, 0.2f, 1f));
// 将光源添加到场景图中
rootNode.addLight(sunLight);
rootNode.addLight(ambientLight);
}
动画控制器
AnimControl
如果你加载的模型带有骨骼动画数据,就可以通过AnimContorl来控制动画。AnimControl中保存了所有的动画数据,它的作用是根据这些数据实时计算骨骼(Skeleton)的姿态。
/**
* 动画模型
*/
private Spatial spatial;
private AnimControl animControl;
private AnimChannel animChannel;
public void simpleInitApp() {
...
// 动画控制器
animControl = spatial.getControl(AnimControl.class);
animControl.addListener(animEventListener);
// 播放“Idle”动画
animChannel = animControl.createChannel();
animChannel.setAnim("Idle");
}
静态模型是没有动画数据的,为避免空指针异常,一般需要判断一下animControl对象是否为空。AnimControl对象为空说明这个模型没有动画数据,或者jME3加载时没有正确加载它的动画数据。
animContorl = spatial.getControl(AnimControl.class);
if (animControl != null) {
...
}
本章的样例代码中没有加这个判断,因为我十分确定Jaime的模型中是有动画数据的。:P
AnimControl中保存了所有的动画数据,通过这个控制器,你能够访问模型的动画数据。例如getAnimationNames()
方法可用于查询动画的名称:
// 显示这个模型中有多少个动画,每个动画的名字是什么。
System.out.println(animControl.getAnimationNames());
结果是:
[Walk, Jumping, Idle, JumpEnd, Punches, SideKick, Wave, Taunt, JumpStart, Run]
对于那些从网上下载的免费模型来说,这个方法可以让你搞清楚模型内部有哪些动画。
AnimChannel
知道了动画的名字,就可以通过AnimChannel来播放对应的动画。首先使用AnimControl#createChannel()
创建动画通道(AnimChannel),然后调用AnimChannel#setAnim(String name)
播放对应的动画。
// 播放“Idle”动画
animChannel = animControl.createChannel();
animChannel.setAnim("Idle");
一般情况下,单个动画通道(AnimChannel)就可以满足播放动画的需要了。但是根据游戏的需要,有时3D动画师会把角色的动作分解成多个动画,以实现各种组合动作的需要。
例如,一个角色身体的不同部位可以有不同的动作:
- 角色的下肢有“行走”、“站立”、“下蹲”、“蹲行”、“跳跃”等动作。角色做出这些动作时,躯干和上肢保持不动。
- 躯干部分有“挺直”、“弯腰”、“后仰”、“匍匐”等动作。角色做出这些动作时,下肢和上肢保持不动。
- 上肢部分有“挥手”、“戒备”、“攻击”、“持枪”、“持刀”、“空手握拳”等动作。角色做出这些动作时,躯干和下肢保持不动。
想要让角色做出“站立挥手”、“下蹲攻击”、“跳起挥手”、“持枪匍匐前进”等复杂动作,可以通过已有的动画进行排列组合。这样一方面节省了3D动画师的工作量,另一方面也减少了动画模型资源的体积。
一个AnimControl可以拥有多个动画通道(AnimChannel),每个通道同一时刻都可以用于播放一个动画序列。创建多个AnimChannel,就可以同时播放多个动画。
下面是一段伪代码,演示2个AnimChannel同时工作:
// 创建2个动画通道
AnimChannel legChannel = animControl.createChannel();
AnimChannel armChannel = animControl.createChannel();
// 让角色做出“跑动攻击”动作
legChannel.setAnim("Run");
armChannel.setAnim("Attack");
动画状态监听器
游戏中的动画通常使用“有限状态机(FSM)”来设计和管理,为此我们需要知道某个动画什么时候播放完毕。
AnimControl中可以注册AnimEventListener接口。当动画播放完毕后,AnimEventListener的实现类将会得到通知。通过实现这个接口,我们就可以在动画结束或改变后进行进一步的处理。
例如:跳跃着陆后,根据跳跃前的状态恢复成“Walk”或“Idle”动画。
public void simpleInitApp() {
...
// 动画控制器
animControl = spatial.getControl(AnimControl.class);
animControl.addListener(animEventListener);
...
}
/**
* 动画事件监听器
*/
private AnimEventListener animEventListener = new AnimEventListener() {
@Override
public void onAnimCycleDone(AnimControl control, AnimChannel channel, String animName) {
if ("JumpStart".equals(animName)) {
// “起跳”动作结束后,紧接着播放“着地”动画。
channel.setAnim("JumpEnd");
channel.setLoopMode(LoopMode.DontLoop);
channel.setSpeed(1.5f);
} else if ("JumpEnd".equals(animName)) {
// “着地”后,根据按键状态来播放“行走”或“闲置”动画。
if (isWalking) {
channel.setAnim("Walk");
channel.setLoopMode(LoopMode.Loop);
} else {
channel.setAnim("Idle");
channel.setLoopMode(LoopMode.Loop);
}
}
}
@Override
public void onAnimChange(AnimControl control, AnimChannel channel, String animName) {
}
};
}
用户控制动画
游戏场景中有些动画是不需要玩家控制的,比如树枝随风摆动、小动物跑跑跳跳、NPC到处闲逛等。而玩家控制的角色模型,需要根据用户输入来进行即时交互。
根据我们前面在学过的知识,首先应当在InputManager中绑定动作消息和触发器,然后再绑定消息和监听器。当监听器得到动作消息后,就可以通过AnimChannel#setAnim(String name)
方法来播放对应的动画。
private boolean isWalking = false;
/**
* 初始化按键
*/
private void initKeys() {
// 按W键行走
inputManager.addMapping(WALK, new KeyTrigger(KeyInput.KEY_W));
// 按空格键跳跃
inputManager.addMapping(JUMP, new KeyTrigger(KeyInput.KEY_SPACE));
inputManager.addListener(actionListener, WALK, JUMP);
}
/**
* 按键动作监听器
*/
private ActionListener actionListener = new ActionListener() {
@Override
public void onAction(String name, boolean isPressed, float tpf) {
/**
* 若Jaime已经处于JumpStart/Jumping/JumpEnd状态,就不要再做其他动作了。
*/
// 查询当前正在播放的动画
String curAnim = animChannel.getAnimationName();
if (curAnim != null && curAnim.startsWith("Jump")) {
return;
}
if (WALK.equals(name)) {// 走
// 记录行走状态
isWalking = isPressed;
if (isPressed) {
// 播放“行走”动画
animChannel.setAnim("Walk");
animChannel.setLoopMode(LoopMode.Loop);// 循环播放
} else {
// 播放“闲置”动画
animChannel.setAnim("Idle");
animChannel.setLoopMode(LoopMode.Loop);
}
} else if (JUMP.equals(name)) {// 跳
if (isPressed) {
// 播放“起跳”动画
animChannel.setAnim("JumpStart");
animChannel.setLoopMode(LoopMode.DontLoop);
animChannel.setSpeed(1.5f);
}
}
}
};
除了setAnim
方法以外,AnimChannel还可以额外设置一些其他的参数:
setLoopMode(LoopMode lm)
设置循环模式,默认为LoopMode.Loop。- LoopMode.DontLoop 只播放一次
- LoopMode.Loop 循环播放。使用这种模式时,动画播放完一遍后,会继续从头开始播放。
- LoopMode.Cycle 轮循播放。使用这种模式时,动画会先顺序播放一遍,然后从末尾逆序再播放一遍,整个过程像荡秋千一样。
setSpeed(float speed)
设置动画播放的速度,默认为1f。- speed不可以小于0.0f
- speed小于1时,播放速度会放慢。
- speed等于1时,按正常速度播放。
- speed大于1时,播放速度会加快。
上面两个方法要在setAnim
方法之后调用才有效果,因为这个方法将会重置循环模式和播放速度。
操纵骨骼
前文中我们介绍了“骨骼蒙皮”的概念,即每一块骨骼通过一定的“权重”来影响“皮肤”。为了让骨骼动画能够正常工作,3D模型中除了保存网格、材质等数据,还必须保存“骨骼蒙皮”数据。
从数据结构的角度来看,每个骨骼(Skeleton)由多个骨头(Bone)以父子关系组成,每个骨头保存了自己的名称、姿态等数据;皮肤实际上就是模型的网格(Mesh),每个网格由多个顶点(Vertex)组成。
“骨骼蒙皮”数据记录的是每个顶点(Vertex)受多个骨头(Bone)之间的影响程度(即权重)。在jME3中,每个顶点最多受4个骨头的影响,这些骨骼的权值之和应该等于1.0。
下面我们将通过一个样例来学习SkeletonControl、SkeletonDebugger的作用,以及如何利用骨骼来给角色模型添加附件。
你能看出这个骨骼中的“骨头”吗?
在下面的例子中,按F1可以显示/隐藏绿色的骨骼调试器;按F2可以启用/禁用骨骼控制器;按F3可以移除/添加Jaime右手的道具。
样例代码
package net.jmecn;
import com.jme3.animation.AnimControl;
import com.jme3.animation.SkeletonControl;
import com.jme3.app.SimpleApplication;
import com.jme3.input.KeyInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.debug.SkeletonDebugger;
import com.jme3.scene.shape.Cylinder;
/**
* 演示Jaime的骨骼
* @author yanmaoyuan
*
*/
public class HelloSkeleton extends SimpleApplication {
/**
* 按F1显示/隐藏SkeletonDebugger。
*/
public final static String TOGGLE_SKELETON_DEBUGGER = "toggle_SkeletonDebugger";
/**
* 按F2启用/禁用SkeletonContorl。
*/
public final static String TOGGLE_SKELETON_CONTROL = "toggle_SkeletonControl";
/**
* 按F3移除/添加Jaime右手的附件。
*/
public final static String TOGGLE_ATTACHMENT = "toggle_attachment";
// 我们的模特:Jaime
private Node jaime;
// 骨骼调试器
private SkeletonDebugger sd;
// 骨骼控制器
private SkeletonControl sc;
// Jaime的右手
private Node rightHand;
// Jaime的棍子
private Spatial stick;
@Override
public void simpleInitApp() {
/**
* 摄像机
*/
cam.setLocation(new Vector3f(8.896082f, 12.328749f, 13.69658f));
cam.setRotation(new Quaternion(-0.09457599f, 0.9038204f, -0.26543108f, -0.32204098f));
flyCam.setMoveSpeed(10f);
/**
* 要有光
*/
rootNode.addLight(new AmbientLight(new ColorRGBA(0.2f, 0.2f, 0.2f, 1f)));
rootNode.addLight(new DirectionalLight(new Vector3f(-1, -2, -3), new ColorRGBA(0.8f, 0.8f, 0.8f, 1f)));
/**
* 加载Jaime的模型
*/
jaime = (Node)assetManager.loadModel("Models/Jaime/Jaime.j3o");
// 将Jaime放大一点点,这样我们能观察得更清楚。
jaime.scale(5f);
rootNode.attachChild(jaime);
// 获得SkeletonControl
sc = jaime.getControl(SkeletonControl.class);
// 打印骨骼的名称和层次关系
System.out.println(sc.getSkeleton());
/**
* 创建一个SkeletonDebugger,用于显示骨骼的形状。
*/
sd = new SkeletonDebugger("debugger", sc.getSkeleton());
jaime.attachChild(sd);
// 将SkeletonDebugger的姿态与Jaime的几何体同步。
Spatial child = jaime.getChild(0);
sd.setLocalTransform(child.getLocalTransform());
// 创建材质
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Green);
mat.getAdditionalRenderState().setDepthTest(false);// 禁用深度测试,实现透视效果。
sd.setMaterial(mat);
/**
* 绑定附件
*/
stick = createAttachment();
rightHand= sc.getAttachmentsNode("hand.L");
rightHand.attachChild(stick);
/**
* 播放骨骼动画
*/
AnimControl animControl = jaime.getControl(AnimControl.class);
animControl.createChannel().setAnim("Walk");
/**
* 用户输入
*/
inputManager.addMapping(TOGGLE_SKELETON_DEBUGGER, new KeyTrigger(KeyInput.KEY_F1));
inputManager.addMapping(TOGGLE_SKELETON_CONTROL, new KeyTrigger(KeyInput.KEY_F2));
inputManager.addMapping(TOGGLE_ATTACHMENT, new KeyTrigger(KeyInput.KEY_F3));
inputManager.addListener(actionListener,
TOGGLE_SKELETON_DEBUGGER, TOGGLE_SKELETON_CONTROL, TOGGLE_ATTACHMENT);
}
/**
* 给Jaime做一根棍子当做武器
* @return
*/
private Spatial createAttachment() {
Node node = new Node("Golden Stick");
// 棍子的中间用黄色的材质
Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
mat.setColor("Diffuse", ColorRGBA.Yellow);
mat.setColor("Specular", ColorRGBA.White);
mat.setColor("Ambient", ColorRGBA.Yellow);
mat.setFloat("Shininess", 2f);
mat.setBoolean("UseMaterialColors", true);
Geometry body = new Geometry("body", new Cylinder(2, 6, 0.02f, 1.2f, true));
body.setMaterial(mat);
// 棍子两端用红色材质。
mat = mat.clone();// 为了省事,直接克隆材质。
mat.setColor("Diffuse", ColorRGBA.Red);
mat.setColor("Ambient", ColorRGBA.Red);
Geometry head1 = new Geometry("head1", new Cylinder(2, 6, 0.02f, 0.4f, true));
Geometry head2 = new Geometry("head2", new Cylinder(2, 6, 0.02f, 0.4f, true));
head1.setMaterial(mat);
head2.setMaterial(mat);
node.attachChild(head1);
node.attachChild(body);
node.attachChild(head2);
head1.move(0, 0, 0.8f);
head2.move(0, 0, -0.8f);
// 稍微调整一下这根棍子的姿态,使它和Jaime的右手掌心契合。
node.rotate(0, FastMath.HALF_PI, 0);
node.move(0.8f, 0.1f, 0.05f);
return node;
}
/**
* 动作监听器
*/
private ActionListener actionListener = new ActionListener() {
@Override
public void onAction(String name, boolean isPressed, float tpf) {
if (isPressed) {
if (TOGGLE_SKELETON_DEBUGGER.equals(name)) {
toggleSkeletonDebugger();
} else if (TOGGLE_SKELETON_CONTROL.equals(name)) {
toggleSkeletonControl();
} else if (TOGGLE_ATTACHMENT.equals(name)) {
toggleAttachment();
}
}
}
};
/**
* 显示或隐藏SkeletonDebugger
*/
private void toggleSkeletonDebugger() {
if (sd.getParent() != null) {
sd.removeFromParent();
} else {
jaime.attachChild(sd);
}
}
/**
* 移除或添加SkeletonContorl
*/
private void toggleSkeletonControl() {
// sc.setEnabled(!sc.isEnable());
if (sc.isEnabled()) {
sc.setEnabled(false);
} else {
sc.setEnabled(true);
}
}
/**
* 移除或添加Jaime右手的附件
*/
private void toggleAttachment() {
if (stick.getParent() != null) {
stick.removeFromParent();
} else {
rightHand.attachChild(stick);
}
}
public static void main(String[] args) {
HelloSkeleton app = new HelloSkeleton();
app.start();
}
}
骨骼控制器
SkeletonControl
动画模型中除了AnimControl以外,还有一个SkeletonControl,这两个控制器共用一套骨骼(Skeleton)。AnimControl的作用是根据动画数据来计算骨骼的姿态,而SkeletonControl的作用是根据“骨骼蒙皮”数据来计算模型的变形。
从模型中获取SkeletonControl非常简单,只需要一行代码:
private Node jaime;
private SkeletonContorl sc;
public void simpleInitApp() {
...
jaime = (Node)assetManager.loadModel("Models/Jaime/Jaime.j3o");
// 获得SkeletonControl
sc = jaime.getControl(SkeletonControl.class);
...
}
我们可以通过Control#setEnable(boolean isEnabled)
方法来启动/禁用控制器。在样例代码中,用户按键F2可以开关SkeletonControl的功能。
/**
* 启用/禁用SkeletonContorl
*/
private void toggleSkeletonControl() {
// sc.setEnabled(!sc.isEnable());
if (sc.isEnabled()) {
sc.setEnabled(false);
} else {
sc.setEnabled(true);
}
}
如果禁用SkeletonControl,AnimControl依然会计算骨骼的姿态,但是模型缺不会再跟随骨骼运动了。
jME3支持硬件蒙皮,即利用GPU来计算模型网格和骨骼的对应关系,这会显著提升游戏画面的刷新率。硬件蒙皮功能默认是开启的,然而有时你可能更倾向于关闭它,例如在硬件不支持这个功能时。
// 关闭硬件蒙皮
sc.setHardwareSkinningPreferred(false);
关闭硬件蒙皮的话,骨骼和网格的数据将由CPU来运算。随着模型顶点数和骨骼数量的增加,这个过程会变得相当耗时,以至于在一些中低端的智能手机上根本无法运行。
降低模型面数、降低骨骼复杂度,是游戏性能优化时常用的手段。
Skeleton
无论是SkeletonControl还是AnimControl,都提供了查询骨骼的方法,例如:
Skeleton ske = sc.getSkeleton();
Skeleton内部使用一个数组保存了所有的Bone对象,可以通过Bone的名称查询到骨骼对象或骨骼的索引号。
Bone bone = ske.getBont("hand.L");
int index = ske.getBontIndex("hand.L");
每个Bone对象都有一个特定的名字,同时保存着它相对于父节点的位移(Postion)、旋转(Rotaion)、缩放(Scale)等数据。SkeletonControl正是通过这些数据和蒙皮数据来计算模型姿态的。
我们可以在控制台下直接打印Skeleton内部的骨骼名称,以及它们之间的层次关系。
System.out.println(sc.getSkeleton());
Jaime的骨骼打印出来是这样的:
Skeleton - 108 bones, 5 roots
TailCtrl.004 bone
TailCtrl.003 bone
TailCtrl.002 bone
TailCtrl.001 bone
Root bone
-IKhand.L bone
-knee.L bone
-knee.R bone
-IKhand.R bone
-Elbow.L bone
-Elbow.R bone
-hips bone
--pelvis bone
---tail.001 bone
----tail.002 bone
-----tail.003 bone
------tail.004 bone
-------tail.005 bone
--------tail.006 bone
---------tail.007 bone
----------tail.008 bone
-----------tail.009 bone
------------tail.010 bone
---thigh.L bone
----shin.L bone
-----foot.L bone
------toe.L bone
---thigh.R bone
----shin.R bone
-----foot.R bone
------toe.R bone
--spine bone
---ribs bone
----neck bone
-----head bone
------IKjawTarget bone
------jaw bone
------LipBottom.R bone
------LipTop.R bone
------LipSide.R bone
------eyebrow.01.R bone
------eyebrow.02.R bone
------eyebrow.03.R bone
------Cheek.R bone
------LipBottom.L bone
------LipTop.L bone
------LipSide.L bone
------eyebrow.01.L bone
------eyebrow.02.L bone
------eyebrow.03.L bone
------Cheek.L bone
------EyeLidTop.R bone
------EyeLidBottom.R bone
------EyeLidTop.L bone
------EyeLidBottom.L bone
------SightTarget bone
-------IKeyeTarget.R bone
-------IKeyeTarget.L bone
------eye.L bone
------eye.R bone
----shoulder.L bone
-----upper_arm.L bone
------forearm.L bone
-------hand.L bone
--------palm.01.L bone
---------finger_index.01.L bone
----------finger_index.02.L bone
-----------finger_index.03.L bone
---------thumb.01.L bone
----------thumb.02.L bone
-----------thumb.03.L bone
--------palm.04.L bone
---------finger_pinky.01.L bone
----------finger_pinky.02.L bone
-----------finger_pinky.03.L bone
--------palm.02.L bone
---------finger_middle.01.L bone
----------finger_middle.02.L bone
-----------finger_middle.03.L bone
--------palm.03.L bone
---------finger_ring.01.L bone
----------finger_ring.02.L bone
-----------finger_ring.03.L bone
----shoulder.R bone
-----upper_arm.R bone
------forearm.R bone
-------hand.R bone
--------palm.01.R bone
---------finger_index.01.R bone
----------finger_index.02.R bone
-----------finger_index.03.R bone
---------thumb.01.R bone
----------thumb.02.R bone
-----------thumb.03.R bone
--------palm.04.R bone
---------finger_pinky.01.R bone
----------finger_pinky.02.R bone
-----------finger_pinky.03.R bone
--------palm.02.R bone
---------finger_middle.01.R bone
----------finger_middle.02.R bone
-----------finger_middle.03.R bone
--------palm.03.R bone
---------finger_ring.01.R bone
----------finger_ring.02.R bone
-----------finger_ring.03.R bone
-IKheel.L bone
-IKheel.R bone
上文搭配此图食用更佳。
骨骼调试器
SkeletonDebugger类可用于显示骨骼的形状。当然,前提是这个模型得绑定过骨骼。
首先,在代码顶部加入对SkeletonDebugger和Material的引用:
import com.jme3.scene.debug.SkeletonDebugger;
import com.jme3.material.Material;
然后,在simpleInitApp()中添加如下语句,使模型的骨骼可见。
// 骨骼调试器
private SkeletonDebugger sd;
public void simpleInitApp() {
...
/**
* 创建一个SkeletonDebugger,用于显示骨骼的形状。
*/
sd = new SkeletonDebugger("debugger", sc.getSkeleton());
jaime.attachChild(sd);
// 创建材质
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Green);
mat.getAdditionalRenderState().setDepthTest(false);// 禁用深度测试,实现光透视效果。
sd.setMaterial(mat);
...
}
对于一般的模型来说,这点代码就够了。不过Jaime的模型有点古怪,作者把它绕Y轴转了180°。直接按上述代码运行的话,会发现骨骼和模型的正面是相反的。
为了使它的骨骼和模型姿态保持一致,需要额外加一点点代码。这个代码不具有通用性,只是为了解决Jaime的问题。
// 将SkeletonDebugger的姿态与Jaime的几何体同步。
Spatial child = jaime.getChild(0);
sd.setLocalTransform(child.getLocalTransform());
结果如下:
SkeletonDebugger本质上是一个Spatial,因此可以使用attachChild方法把它添加到场景中。在样例代码中,这个SkeletonDebugger对象的父节点是Jaime。
用户按下F1键,可以显示或隐藏SkeletonDebugger。
/**
* 显示或隐藏SkeletonDebugger
*/
private void toggleSkeletonDebugger() {
if (sd.getParent() != null) {
sd.removeFromParent();
} else {
jaime.attachChild(sd);
}
}
绑定附件
在很多3D游戏中,都会出现让角色“拿起”道具的情况,例如更换角色手冢的武器。在jME3中,我们可以通过SkeletonControl#getAttachmentNode(String boneName)
来获取于某个骨头绑定的节点,然后把道具的模型添加到这个节点中,以此实现绑定附件的功能呢。
在样例代码中,我是用红黄两色的圆柱体来给Jaime制作了一根棍子,用这根棍子充当Jaime的道具。
把这根棍子绑定到Jaime右手的代码是这样的。
// Jaime的右手
private Node rightHand;
// Jaime的棍子
private Spatial stick;
public void simpleInitApp() {
/**
* 绑定附件
*/
stick = createAttachment();
rightHand = sc.getAttachmentsNode("hand.L");
rightHand.attachChild(stick);
...
}
注意,SkeletonControl是根据骨骼的名字来查找附件节点的。如果骨骼的命名不规范,会给程序带来很大的麻烦。Jaime这个模型有点小bug,"hand.L"应该是左手骨骼的命名,但实际上它却代表右手。
由于附件模型只是和骨骼的节点绑定,它的运动并不需要蒙皮计算。因此即使禁用了SkeletonControl,附件模型依然会随着Skeleton的运动而运动。
正常运行结果:
禁用SkeletonControl后,角色静止不动,但是棍子还在自顾自地运动。
由于附件节点本质上就是个Node,因此我们可以attach/dettach等方法来操纵场景。在样例代码中,用户按F3可以移除或添加Jaime右手的附件。
/**
* 移除或添加Jaime右手的附件
*/
private void toggleAttachment() {
if (stick.getParent() != null) {
stick.removeFromParent();
} else {
rightHand.attachChild(stick);
}
}
角色换装
既然已经说到了这里,那么顺便再稍微介绍一下如何实现角色换装。
角色身上的武器、盾牌、戒指、挂件之类的装备不需要按下面的方法干,直接找准骨骼绑定附件即可。
穿戴类装备一般要经过这么几步:
- 为游戏角色建立一套**[重点]标准身形和标准骨骼**,并绑定骨骼。
- 根据可换装的部位,把标准身形**[重点]切割成若干个部件**,例如:头发、头、脖子、手臂、躯干、腿、足等。
- 针对每个部位,制作不同的装备模型。制作时,注意部位之间的**[重点]接缝要对齐**,避免断手断脚的情况。
- 制作好的装备模型,[重点]要和标准骨骼重新绑定。
- 在程序中,更换装备后要**[重点]替换对应部位的模型**,[重点]并重新和骨骼绑定。
总结一下:把角色模型肢解,然后复用同一个SkeletonControl和AnimControl。
更详细教程请阅读:3D游戏中角色的换装原理-落樱之剑实例图文详细剖析
运动路径
运动控制器(MotionEvent
)可用于遥控物体和摄像机,让它们沿着固定轨迹移动,移动的路径使用MotionPath来描述。MotionPath
中保存了轨迹上的关键路径点(WayPoints),具体的路线将由算法来生成。路径可以是直线(SplineType.Linear
),也可以是平滑的曲线(SplineType.CatmullRom
),一切都看你的需求。
官方案例:
- 控制物体运动: TestMotionPath.java
- 控制摄像机运动:TestCameraMotionPath.java
样例代码
按键说明:
P: 隐藏/显示路线
I: 切换路线类型(直线/曲线)
空格:开始/停止移动
U: 增加曲率(仅曲线模式下有效)
J: 减少曲率(仅曲线模式下有效)
代码如下:
package net.jmecn.anim;
import com.jme3.animation.AnimChannel;
import com.jme3.animation.AnimControl;
import com.jme3.app.SimpleApplication;
import com.jme3.cinematic.MotionPath;
import com.jme3.cinematic.events.MotionEvent;
import com.jme3.cinematic.events.MotionEvent.Direction;
import com.jme3.input.KeyInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Spline.SplineType;
import com.jme3.math.Vector3f;
import com.jme3.scene.Spatial;
/**
* 演示运动路径
* @author yanmaoyuan
*
*/
public class TestMotion extends SimpleApplication {
public static void main(String[] args) {
// 启动程序
TestMotion app = new TestMotion();
app.start();
}
/**
* 缩放系数
*/
final static float SCALE_FACTOR = 5f;
private Spatial player;// 玩家
private Spatial stage;// 舞台
private boolean active = true;
private boolean playing = false;
private MotionPath motionPath;
private MotionEvent motionControl;
@Override
public void simpleInitApp() {
flyCam.setMoveSpeed(10);
initLights();
initInputs();
initMotionPath();
stage = assetManager.loadModel("Models/Stage/Stage.j3o");
stage.scale(SCALE_FACTOR);
rootNode.attachChild(stage);
player = assetManager.loadModel("Models/Jaime/Jaime.j3o");
rootNode.attachChild(player);
AnimControl ac = player.getControl(AnimControl.class);
AnimChannel channel = ac.createChannel();
channel.setAnim("Run");
channel.setSpeed(2f);
motionControl = new MotionEvent(player, motionPath);
// 在行进中,注视某个位置
// Vector3f position = new Vector3f(0, 0, 0);
// motionControl.setLookAt(position, Vector3f.UNIT_Y);
// motionControl.setDirectionType(Direction.LookAt);
// 在行进中,面朝固定方向
// Quaternion rotation = new Quaternion().fromAngleNormalAxis(-FastMath.HALF_PI, Vector3f.UNIT_Y);
// motionControl.setRotation(rotation);
// motionControl.setDirectionType(Direction.Rotation);
// 在行进中,面朝前进方向
motionControl.setDirectionType(Direction.Path);
// 设置走完全程所需的时间(单位:秒)。
motionControl.setInitialDuration(10f);
}
/**
* 初始化光源
*/
private void initLights() {
AmbientLight ambient = new AmbientLight(new ColorRGBA(0.4f, 0.4f, 0.4f, 1f));
DirectionalLight sun = new DirectionalLight();
sun.setDirection(new Vector3f(0.6486864f, -0.72061276f, 0.24479222f));
sun.setColor(new ColorRGBA(0.8f, 0.8f, 0.8f, 1f));
rootNode.addLight(ambient);
rootNode.addLight(sun);
}
/**
* 路径点数据
*/
final static float[] WayPoints = {
1.3660254f, -1f, 0.5f,// 0
0.8660254f, -1f, 0.5f,// 1
-0.8660254f, 0f, 0.5f,// 2
-1.3660254f, 0f, 0.5f,// 3
-1.3660254f, 0f, -0.5f,// 4
-0.8660254f, 0f, -0.5f,// 5
0.8660254f, 1f, -0.5f,// 6
1.3660254f, 1f, -0.5f// 7
};
/**
* 建造路径点
*/
private void initMotionPath() {
motionPath = new MotionPath();
// 路径点个数
int count = WayPoints.length / 3;
for(int i = 0; i < count; i++) {
// 按比例放大顶点坐标
int n = i * 3;
float x = SCALE_FACTOR * WayPoints[n];
float y = SCALE_FACTOR * WayPoints[n + 1];
float z = SCALE_FACTOR * WayPoints[n + 2];
motionPath.addWayPoint(new Vector3f(x, y, z));
}
motionPath.setPathSplineType(SplineType.Linear);
motionPath.enableDebugShape(assetManager, rootNode);
}
/**
* 初始化输入
*/
private void initInputs() {
inputManager.addMapping("display_hidePath", new KeyTrigger(KeyInput.KEY_P));
inputManager.addMapping("SwitchPathInterpolation", new KeyTrigger(KeyInput.KEY_I));
inputManager.addMapping("tensionUp", new KeyTrigger(KeyInput.KEY_U));
inputManager.addMapping("tensionDown", new KeyTrigger(KeyInput.KEY_J));
inputManager.addMapping("play_stop", new KeyTrigger(KeyInput.KEY_SPACE));
ActionListener acl = new ActionListener() {
public void onAction(String name, boolean keyPressed, float tpf) {
if (name.equals("display_hidePath") && keyPressed) {
if (active) {
active = false;
motionPath.disableDebugShape();
} else {
active = true;
motionPath.enableDebugShape(assetManager, rootNode);
}
}
if (name.equals("play_stop") && keyPressed) {
if (playing) {
playing = false;
motionControl.stop();
} else {
playing = true;
motionControl.play();
}
}
if (name.equals("SwitchPathInterpolation") && keyPressed) {
if (motionPath.getPathSplineType() == SplineType.CatmullRom){
motionPath.setPathSplineType(SplineType.Linear);
} else {
motionPath.setPathSplineType(SplineType.CatmullRom);
}
}
if (name.equals("tensionUp") && keyPressed) {
motionPath.setCurveTension(motionPath.getCurveTension() + 0.1f);
System.err.println("Tension : " + motionPath.getCurveTension());
}
if (name.equals("tensionDown") && keyPressed) {
motionPath.setCurveTension(motionPath.getCurveTension() - 0.1f);
System.err.println("Tension : " + motionPath.getCurveTension());
}
}
};
inputManager.addListener(acl, "display_hidePath", "play_stop", "SwitchPathInterpolation", "tensionUp", "tensionDown");
}
}
路径点
当拍摄电视/电影时,导演告诉演员要走到哪里(例如在舞台的地板上画标记)。摄影师经常把摄像机安装在轨道上(所谓的轨道车),这样他们可以更容易地跟拍复杂的场景。
在jME3中,你可以使用MotionPath来指定物体或摄像机的运动路径。当游戏画面刷新时,MotionPath会自动移动物体的位置,让它从一个点运动到下一个点。
- **路径点(way point)**代表路径上的一个位置。
- **运动路径(motion path)**是一系列路径点的集合。
路径的最终形状,是根据路径点的坐标,通过线性插值或Catmull-Rom曲线插值算法生成的。
创建路径
实例化一个MotionPath对象,然后将路径点的坐标添加进去。
/**
* 路径点数据
*/
final static float[] WayPoints = {
1.3660254f, -1f, 0.5f, // 0
0.8660254f, -1f, 0.5f, // 1
-0.8660254f, 0f, 0.5f, // 2
-1.3660254f, 0f, 0.5f, // 3
-1.3660254f, 0f, -0.5f, // 4
-0.8660254f, 0f, -0.5f, // 5
0.8660254f, 1f, -0.5f, // 6
1.3660254f, 1f, -0.5f// 7
};
/**
* 建造路径点
*/
private void initMotionPath() {
motionPath = new MotionPath();
// 路径点个数
int count = WayPoints.length / 3;
for (int i = 0; i < count; i++) {
int n = i * 3;
float x = WayPoints[n];
float y = WayPoints[n + 1];
float z = WayPoints[n + 2];
motionPath.addWayPoint(new Vector3f(x, y, z));
}
// 设置路径为直线
motionPath.setPathSplineType(SplineType.Linear);
// 显示路径
motionPath.enableDebugShape(assetManager, rootNode);
}
你可以使用下列方法来配置运动路径。
方法 | 用途 |
---|---|
path.setCycle(boolean isCycle) | 这是路径是否形成闭环。参数为true时,将形成闭合路径;为false时,起点和终点将会分离。 |
path.addWayPoint(Vector3f vector) | 向路径中添加一个路径点,添加的顺序即路线行进的顺序。 |
path.removeWayPoint(Vector3f vector) removeWayPoint(int index) | 从路径中移除一个路径点。你可以指定要移除的坐标对象或者索引。 |
path.setCurveTension(float curveTension) | 设置曲线的舒张系数(仅对Catmull-Rom曲线有意义)。值为 0.0f 时,形成的将会是直线;值为 1.0f 时,形成的是一条非常圆滑的曲线。 |
path.getNbWayPoints() | 返回路径点的数量。 |
path.enableDebugShape(AssetManager assetManager, Node rootNode) | 将运动路径显示出来,用于开发调试。 |
path.disableDebugShape() | 隐藏路径,一般用于项目发布时。 |
控制物体运动
MotionPath要和MotionEvent配合使用,才能控制物体运动。MotionEvent负责控制物体沿着MotionPath定义的路径移动。
若已有运动路径 path ,物体 player,欲使player沿着path运动,则需要实例化一个MotionEvent对象。
MotionPath path = ..
Spatial player = ..
MotionEvent motionControl = new MotionEvent(player, path);
如果想移动摄像机,可以先创建一个CameraNode,把摄像机绑定到这个CameraNode上,再用MotionEvent来控制CameraNode沿着path运动。
MotionPath path = ..
CameraNode camNode = new CameraNode("Motion cam", cam);
camNode.setControlDir(ControlDirection.SpatialToCamera);
MotionEvent motionControl = new MotionEvent(camNode , path);
你可以通过下列方法来配置MotionEvent的工作方式。
方法 | 用途 |
---|---|
motionControl.setDirectionType(Direction dir) | 设置物体在运动时,正面应该朝向什么方向。例如Direction.Path可以让物体面朝路径的前方。 |
motionControl.setRotation(Quaternion rotation); | 若希望物体面朝向固定方向(Direction.Rotation),则需要调用此方法来设置旋转方向。 |
motionControl.setLookAt(Vector3f position, Vector3f up) | 若希望物体始终“凝视”某个位置(Direction.LookAt),则需要条用此方法来设置“凝视”的坐标点和摄像机的顶部方向(常用Vector3f.UNIT_Y)。 |
motionControl.setInitialDuration(float time) | 若希望物体在固定时间内走完全程,则需要调用此方法来设置所需的时间(单位:秒)。 |
motionControl.setLoopMode(LoopMode mode) | 设置动画的循环模式,用法和AnimChannel的LoopMode是一样的。DontLoop表示不循环,Loop表示顺序循环,Cycle表示往复循环。 |
运动路径监听器
通过MotionPathListener可以监控物体在MotionPath上的运动状态。当物体到达某个路径点时,监听器的onWayPointReach()
方法就会被触发,并接收到MotionEvent对象和路径点的索引号(wayPointIndex)作为参数。
下面的例子比较简单,每当物体到达一个路径点时,就把这个路径点的索引号打印出来。在实际的游戏中,你可以在特定的路径点触发一些行为,比如播放音乐、开门等。
path.addListener( new MotionPathListener() {
public void onWayPointReach(MotionEvent control, int wayPointIndex) {
if (path.getNbWayPoints() == wayPointIndex + 1) {
println(control.getSpatial().getName() + " 已到达终点。 ");
} else {
println(control.getSpatial().getName() + " 已到达路径点: " + wayPointIndex);
}
}
});
剧情动画
电影化(Cinematics)工具可以让你制作剧情动画。你可以遥控3D场景中的多个角色,让他们运动到指定位置、播放音频文件、播放动画、弹出字幕等等。
样例代码:TestCinematic.java
Cinematics的用法其实并不复杂,重要的是先设计好动画的脚本。由于目前使用Cinematics的人并不多,本章教程也就到此为止,如果你对jME3的电影化工具感兴趣,可以本文后面的评论区留言。
官方教程(英文):
https://jmonkeyengine.github.io/wiki/jme3/advanced/cinematics.html
https://hub.jmonkeyengine.org/t/cinematics-system-for-jme3/14636
https://hub.jmonkeyengine.org/t/scripting-in-jme3-with-groovy-all-about-it/26516
https://jmonkeyengine.github.io/wiki/jme3/scripting.html
更多样例代码
如果你希望详细了解这些各种动画相关的API,jME官方提供了很多关于动画的样例,诸如:
- jme3test.helloworld.HelloAnimation
- jme3test.model.anim
- TestBlenderAnim 播放Blender动画
- TestBlenderObjectAnim 播放Blender动画
- TestOgreAnim 播放Orge动画
- TestOgreComplexAnim 播发Orge复杂动画
- TestAnimationFactory 通过AnimationFactory来生成动画。
- TestSpatialAnim 使用AnimationFactory生成动画,控制Spatial运动
- TestCustomAnim 用代码来创建自定义骨骼动画。
- TestAttachmentsNode 演示物体与骨骼的绑定,例如让玩家手持武器。
- TestAnimBlendBug
- TestHWSkinning 演示硬件蒙皮
- TestSkeletonControlRefresh
- TestModelExportingCloning
- jme3test.animation
- TestMotionPath 演示使用MotionPath来控制物体的运动。
- TestCameraMotionPath 演示使用MotionPath来控制摄像机的移动。
- TestCinematic 演示“电影化”
- SubtitleTrack 演示自定义电影事件。
- TestJaime 使用“电影化”工具,让Jaime来表演一段动作。