[SOLVED] Question about new Animation System - LoopMode, AnimEventListener, retarget animations

I’d like to know how to set the LoopMode of old AnimChannel with the new animation system based on AnimComposer

animChannel.setLoopMode(loopMode);
3 Likes

There is not a LoopMode in the new system.

You can use alternatives proposed here:

1 Like

But there is a bug or a feature, that you really can’t implement the Cycle mode. If you succeed somehow, please let me know. I got stuck on that (following @Ali_RS instructions). There was a PR that never got in that supposedly would have fixed the problem.

thanks @Ali_RS for your replay. Could you help me to translate these instructions of old animation system to apply two different animation simultaneously using two channels with the new AnimComposer system? If you want, write me the code here please. I would like to upgrade my project’s animation management system to the version of jme3.3.2 (GitHub - capdevon/Archer-Game-Template: A Third Person Shooter demo made with jMonkeyEngine build with v3.3.2-stable. Gradle Project)

here old version:

Spatial model = assetManager.loadModel("Models/Sinbad/Sinbad.mesh.xml");
AnimControl animControl = model.getControl(AnimControl.class);
AnimChannel feetChannel = animControl.createChannel();
AnimChannel torsoChannel = animControl.createChannel(); 

torsoChannel.setAnim("RunTop");
feetChannel.setAnim("RunBase");

@tonihele I will share the code as soon as I have all the necessary information.

1 Like

How do you specify bones to channels in old system?

Here is an example code in new system. Modify masks how ever you want and add/remove the joints to mask as needed.

AnimComposer ac = model.getControl(AnimComposer.class);
SkinningControl skin = model.getControl(SkinningControl.class);

Armature armature = skin.getArmature();

ArmatureMask legsMask = ArmatureMask.createMask(armature, "thigh.L"); // replace with proper bone name from the model
legsMask.addFromJoint(armature, "thigh.R"); // replace with proper bone name from the model
ac.makeLayer("Legs", legsMask);

ac.setCurrentAction("RunTop"); // plays on default layer that includes whole armature.
ac.setCurrentAction("RunBase", "Legs"); // plays only on legs. It will overide the default layer
3 Likes

thanks for you help @Ali_RS. I do some experiments with the new system.

Hi guys, I did some experiments. I will try to be brief and exhaustive in my reasoning. If you have a little patience it will be worth it :slight_smile:

I tried my best, any suggestions are welcome to improve the proposed solution and keep the code simple and easy to reuse.

I have encapsulated the functions of the new animation management system in the ‘Animator’ class, which hides the complexities of the system and provides the developer with an easy to use interface.

  • the ‘setActionMapping(List lstAnimations, ActionAnimEventListener animListener)’ method allows you to create specific ‘CustomAction’ with name, layer, loop mode, speed configured in the ‘Animation3’ class, and to associate a desired listener to them.

  • the ‘setAnimation(Animation3 anim)’ method allows you to set the desired animation by checking that it is not already running. It can be used both with and without ‘CustomActions’.

The ‘AdapterControl’ class extends ‘AbstractControl’ leaving the inherited methods empty and add some useful functions like ‘getComponentInChild’ to recursively search for a ‘Control’ class in the ‘spatial’ children. (see this for more details: Archer-Game-Template/AdapterControl.java at main · capdevon/Archer-Game-Template · GitHub)

The ‘Animation3’ class is a simple container with which you can configure your favorite settings such as name, LoopMode and speed of the animation we are going to run. (see this for more details: https://github.com/capdevon/Archer-Game-Template/blob/main/src/main/java/com/capdevon/engine/Animation3.java)

	public class Animator extends AdapterControl {
		
		private AnimComposer animComposer;
		private SkinningControl skinningControl;
	
		private final Map<String, CustomAction> animationMap = new HashMap<>();
		private String currentAnim;
		
		@Override
		public void setSpatial(Spatial sp) {
			super.setSpatial(sp);
			if (spatial != null) {
				animComposer = getComponentInChild(AnimComposer.class);
				skinningControl = getComponentInChild(SkinningControl.class);
				
				System.out.println("--Animations: " + animComposer.getAnimClipsNames());
				System.out.println("--List Bones: " + AnimUtils.listBones(skinningControl.getArmature()));
			}
		}
		
		/**
		 * @param lstAnimations
		 * @param animListener
		 */
		public void setActionMapping(List<Animation3> lstAnimations, ActionAnimEventListener animListener) {
			for (Animation3 anim : lstAnimations) {
				String animName = anim.name;
				boolean isLooping = (anim.loopMode == LoopMode.Loop);
				
				// Get action registered with specified name. It will make a new action if there isn't any.
				Tween delegate = animComposer.action(animName);
				// Configure custom action with specified name, layer, loop, speed and listener.
				CustomAction action = new CustomAction(delegate, animComposer, animName, AnimComposer.DEFAULT_LAYER, isLooping);
				action.setSpeed(anim.speed);
				action.setAnimEventListener(animListener);
				// Register custom action with specified name.
				animComposer.addAction(animName, action);
				
				// Add custom action to map 
				animationMap.put(animName, action);
			}
		}
		
		/**
		 * Run animation
		 * @param anim
		 */
		public void setAnimation(Animation3 anim) {
			String animName = anim.name;
			
			if (!animName.equals(currentAnim)) {
				CustomAction action = animationMap.get(animName);
				if (action != null) {
					// play animation mapped on custom action.
					action.playAnimation();
				} else {
					// play animation in a traditional way.
					animComposer.setCurrentAction(animName);
				}
				currentAnim = animName;
			}
		}
	}

The ‘AnimDefs’ interface allows you to quickly declare the animations contained in our model in a centralized place.

	public interface AnimDefs {
	    final Animation3 Idle           = new Animation3("Idle", LoopMode.Loop, .2f);
	    final Animation3 Running        = new Animation3("Running", LoopMode.Loop);
	    final Animation3 Running_2      = new Animation3("Running_2", LoopMode.Loop);
	    final Animation3 Aim_Idle       = new Animation3("Aim_Idle", LoopMode.DontLoop);
	    final Animation3 Aim_Overdraw   = new Animation3("Aim_Overdraw", LoopMode.DontLoop);
	    final Animation3 Aim_Recoil     = new Animation3("Aim_Recoil", LoopMode.DontLoop);
	    final Animation3 Draw_Arrow     = new Animation3("Draw_Arrow", LoopMode.DontLoop);
	}

I have extended and generalized the functionality of the ‘CustomAction’ class based on the ‘CharacterAction’ you provided me as an example.

I designed the ‘ActionAnimEventListener’ interface based on the old version ‘AnimEventListener’, updating the methods with similar parameters.

	public interface ActionAnimEventListener {

		public void onAnimCycleDone(CustomAction action, AnimComposer animComposer, String animName);
		
		public void onAnimChange(CustomAction action, AnimComposer animComposer, String animName);
	}
	public class CustomAction extends BaseAction {

		private ActionAnimEventListener animListener;
		private AnimComposer animComposer;
		private String animName;
		private String layer;
		private boolean loop;

		public CustomAction(Tween delegate, AnimComposer animComposer, String animName, String layer, boolean loop) {
			super(delegate);
			this.animComposer = animComposer;
			this.animName = animName;
			this.layer = layer;
			this.loop = loop;
		}

		public boolean isLooping() {
			return loop;
		}

		public void setLooping(boolean loop) {
			this.loop = loop;
		}
		
		public ActionAnimEventListener getAnimEventListener() {
			return animListener;
		}

		public void setAnimEventListener(ActionAnimEventListener animListener) {
			this.animListener = animListener;
		}

		public void playAnimation() {
			animComposer.setCurrentAction(animName);
			notifyAnimChange(animName);
		}

		@Override
		public boolean interpolate(double t) {
			boolean running = super.interpolate(t);
			if (!loop && !running) {
				// animation done running...
				// now we can remove this action from the layer it is attached to
				animComposer.removeCurrentAction(layer);
				notifyAnimCycleDone(animName);
			}
			return running;
		}
		
		void notifyAnimChange(String name) {
			if (animListener != null)
				animListener.onAnimChange(this, animComposer, name);
		}

		void notifyAnimCycleDone(String name) {
			if (animListener != null)
				animListener.onAnimCycleDone(this, animComposer, name);
		}

		@Override
		public String toString() {
			return "CustomAction [animName=" + animName + ", layer=" + layer + ", loop=" + loop + "]";
		}

	}

The ‘PlayerControl’ class is used to test the ‘ActionAnimEventListener’ interface in order to have finer control over animation events.

	public class PlayerControl extends AdapterControl implements ActionAnimEventListener {
		
		private Animator animator;
		
		@Override
		public void setSpatial(Spatial sp) {
			super.setSpatial(sp);
			if (spatial != null) {
				animator = getComponent(Animator.class);
				
				animator.setActionMapping(Arrays.asList(
						AnimDefs.Idle, 
						AnimDefs.Running,
						AnimDefs.Running_2, 
						AnimDefs.Aim_Idle,
						AnimDefs.Aim_Overdraw, 
						AnimDefs.Aim_Recoil, 
						AnimDefs.Draw_Arrow
						), this);
			}
		}
		
		@Override
		public void onAnimCycleDone(CustomAction action, AnimComposer animComposer, String animName) {
			System.out.println("onAnimCycleDone: " + action);

			if (animName.equals(AnimDefs.Aim_Recoil.name)) {
				animator.setAnimation(AnimDefs.Draw_Arrow);

			} else if (animName.equals(AnimDefs.Draw_Arrow.name)) {
				animator.setAnimation(AnimDefs.Aim_Overdraw);
			}
		}

		@Override
		public void onAnimChange(CustomAction action, AnimComposer animComposer, String animName) {
			System.out.println("onAnimChange: " + action);

			if (animName.equals(AnimDefs.Aim_Recoil.name) || animName.equals(AnimDefs.Draw_Arrow.name)) {
				System.out.println("\t setWeaponCharging");

			} else if (animName.equals(AnimDefs.Aim_Overdraw.name)) {
				System.out.println("\t setWeaponReady");
			}
		}
	};
2 Likes

the Main class

public class Test_NewAnimSystem extends SimpleApplication {

	/**
	 * @param args
	 */
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		Test_NewAnimSystem app = new Test_NewAnimSystem();
		app.setPauseOnLostFocus(false);
		app.start();
	}

	private final String MODEL = "Models/gltf2/Archer/archer.gltf";
	//--Animations: [Running_2, Draw_Arrow, Idle, Aim_Idle, Running, Water_Moving, Swimming, Water_Idle, Aim_Recoil, Aim_Overdraw]


	@Override
	public void simpleInitApp() {
		// TODO Auto-generated method stub
		stateManager.attach(new DefaultSceneAppState());
		setupCharacter();
		
		cam.setLocation(new Vector3f(-1.9500906f, 2.3003938f, -0.9468966f));
		cam.setRotation(new Quaternion(0.23329076f, 0.45934555f, -0.12641276f, 0.8477009f));
	}

	private void setupCharacter() {
		ModelKey key = new ModelKey(MODEL);
		Node model = (Node) assetManager.loadModel(key);
		rootNode.attachChild(model);
		
		model.addControl(new Animator());
		model.addControl(new PlayerControl());
		
		Animator animator = model.getControl(Animator.class);
		
		inputManager.addMapping("STATE_IDLE", new KeyTrigger(KeyInput.KEY_1));
		inputManager.addMapping("STATE_RUNNING", new KeyTrigger(KeyInput.KEY_2));
		inputManager.addMapping("STATE_AIM", new KeyTrigger(KeyInput.KEY_3));
		
		inputManager.addListener(new ActionListener() {
			@Override
		    public void onAction(String name, boolean isPressed, float tpf) {
		        if (name.equals("STATE_IDLE") && isPressed) {
		        	animator.setAnimation(AnimDefs.Idle);
		        	
				} else if (name.equals("STATE_RUNNING") && isPressed) {
					animator.setAnimation(AnimDefs.Running);
					
				} else if (name.equals("STATE_AIM") && isPressed) {
					animator.setAnimation(AnimDefs.Aim_Recoil);
				}
		    }
		}, "STATE_IDLE", "STATE_RUNNING", "STATE_AIM");
	}
1 Like

Close to what I did, does it CYCLE though? :slight_smile:

I did some experiments. I thought that multiplying the animation speed by -1 at the end of each ‘loop’ was the right way to find a solution.

		@Override
		public boolean interpolate(double t) {
			boolean running = super.interpolate(t);
			if (cycle && !running) {
				speed *= -1;
			}
			return running;
		}

But it doesn’t work because as @Ali_Rs says in this thread Added feature request #1019 by Ali-RS · Pull Request #1038 · jMonkeyEngine/jmonkeyengine · GitHub

[…] ‘time never become >= currentAction.getLength() when animation is backward (speed < 0)’

thus this line never returns false

jmonkeyengine/jme3-core/src/main/java/com/jme3/anim/AnimComposer.java
Line 240

layer.running = currentAction.interpolate(layer.time); 

At the moment I don’t know how to implement the functionality you would like.

1 Like

I came to the same conclusion yes… :frowning:

Not sure if this will help but I think I had solved it like this in my PR by modifying AnimComposer

Added feature request #1019 by Ali-RS · Pull Request #1038 · jMonkeyEngine/jmonkeyengine · GitHub

1 Like

Hi @Ali_RS, with the old animation system I used this script to copy animations from one Spatial to another. The two Spatials are identical models with the same skeleton. However, they contain different animations. I adapted the script with the new animation management system, but the trick doesn’t work. Could you help me solve the problem? I modelli e le animazioni sono del sito Mixamo.

old script:

    public static void copyAnimation(Spatial from, Spatial to) {
        AnimControl acFrom = from.getControl(AnimControl.class);
        AnimControl acTo = to.getControl(AnimControl.class);

        for (String animName : acFrom.getAnimationNames()) {
            if (!acTo.getAnimationNames().contains(animName)) {
                System.out.println("Copying Animation: " + animName);
                Animation anim = acFrom.getAnim(animName);
                acTo.addAnim(anim);
            }
        }
    }

new script:

	public static void copyAnimation(Spatial from, Spatial to) {
		AnimComposer acFrom = from.getControl(AnimComposer.class);
		AnimComposer acTo = to.getControl(AnimComposer.class);

		for (String animName : acFrom.getAnimClipsNames()) {
			if (!acTo.getAnimClipsNames().contains(animName)) {
				System.out.println("Copying Animation: " + animName);
				AnimClip anim = acFrom.getAnimClip(animName);
				acTo.addAnimClip(anim);
			}
		}
	}
	private final String RIFLE  = "Models/gltf2/RiflePack/rifle.gltf";
    private final String ARCHER = "Models/gltf2/Archer/archer.gltf";
	
	@Override
    public void simpleInitApp() {
		charModel = (Node) assetManager.loadModel(ARCHER);
        AnimUtils.copyAnimation(assetManager.loadModel(RIFLE), charModel);
		
		...
	}
	

Yes, unfortunately, that will not work like that for the new system because in the new system a TansformTrack is keeping a reference to the Joint (Bone) of the model so you need to clone tracks (using jmeClone()) and update their target to refer to the joint of the new model.

See here for a solution

Or you can also use Wes library to retarget an animation

3 Likes

Please let me know in case you were not able to solve your problem using any of the above approaches.

1 Like

Thank you so much for the reference material. Give me some time to study the different approaches. Thanks for your availability, I will let you know soon.

1 Like

It works! Thanks for the help @Ali_RS. Here is the final code:

    /**
     * @param from
     * @param to 
     */
	public static void copyAnimation(Spatial from, Spatial to) {

		AnimComposer source = from.getControl(AnimComposer.class);
		AnimComposer target = to.getControl(AnimComposer.class);
		Armature targetArmature = to.getControl(SkinningControl.class).getArmature();

		copyAnimation(source, target, targetArmature);
	}
	
	/**
	 * 
	 * @param source
	 * @param target
	 * @param targetArmature
	 */
	public static void copyAnimation(AnimComposer source, AnimComposer target, Armature targetArmature) {
		for (String animName : source.getAnimClipsNames()) {
			if (!target.getAnimClipsNames().contains(animName)) {
				System.out.println("Copying Animation: " + animName);

				AnimClip clip = new AnimClip(animName);
				clip.setTracks( copyAnimTracks(source.getAnimClip(animName), targetArmature) );
				target.addAnimClip(clip);
			}
		}
	}
	
	/**
	 * 
	 * @param sourceClip
	 * @param targetArmature
	 * @return
	 */
	private static AnimTrack[] copyAnimTracks(AnimClip sourceClip, Armature targetArmature) {
		
		SafeArrayList<AnimTrack> tracks = new SafeArrayList<>(AnimTrack.class);
		
		for (AnimTrack track : sourceClip.getTracks()) {

			TransformTrack tt = (TransformTrack) track;
			
			if (tt.getTarget() instanceof Joint) {
				Joint joint = (Joint) tt.getTarget();
				HasLocalTransform target = targetArmature.getJoint(joint.getName());
				TransformTrack newTrack = new TransformTrack(target, tt.getTimes(), tt.getTranslations(), tt.getRotations(), tt.getScales());
				tracks.add(newTrack);
			}
		}

		return tracks.getArray();
	}

4 Likes

Glad it works.

For optimization purpose I think you can try to change this line

TransformTrack newTrack = new TransformTrack(target, tt.getTimes(), tt.getTranslations(), tt.getRotations(), tt.getScales());

to

TransformTrack newTrack = tt.jmeClone();
newTrack.setTarget(target);

this will save you some memory space by sharing key frames data (translations/rotations/scales) on all clones.

Edit:

I guess this also will speed up the retargeting process a bit by avoiding array compressing/decompressing process at these lines

4 Likes

does this still work, I see that there are links to classes and a fair bit of pasted code to sort through, I have to move to gltf, but the lack of “built in” listeners in the composer animation system was making it difficult for me to wrap my head around changing my animation code which relies heavily on onAnimCycleDone for chaining animations into input based sequences