Root Motion & Blend Trees in PlayCanvas

So I’ve revisited the whole Root Motion project as it didn’t work so well for blended animations, this lead me on to thinking about the kind of animation blending I wanted and I realised I really needed to be able to play animations on different parts of the body at the same time. I also find Mecanim very useful with its 2d blend axes and state machines so I’ve built a version for PlayCanvas: you can try it out here: Root Motion Demo The project containing the code is here: Project

The demo blends 2d axis movement in X and Y (horizontal and vertical) using a range of animations. It uses state machines to control a forward jump and a clapping animation which is played on the upper body only while the lower body maintains the requested movement. All character movement is driven by the animations.

Note I don’t have a backwards turn animation for the character so I’ve allowed strafe and walk backwards - strafe’s are hard to blend well (in Unity too) so this backwards turn movement is less than perfect, it would be as good as the forward turns with the complete set of animations.

It’s designed to be added to any project and has no external dependencies. The file needed is motion.js and it should be attached to a model with an animation component containing all of the required animations.

When this is done there is an entity.motion object that you can use to create state machines and blends.

Firstly all of the animations on the model are available in motion.blends which adds the necessary extra features. The next thing to do is define some animation states.

In the demo all normal movement is contained in a single moving state. This is how you would declare one:

		var moving = motion
			.stateMachine() //Get the layer 0 state machine
			.state('moving') //declare a state called 'moving'

All of the demo states and controls are in the test-motion.js file

Once you’ve created the state you need to give it a “blend” to work with. Moving is going to use axis based blending. In other words some variables from the code are going to be mapped on to 2 axes (‘v’ and ‘h’) to represent what the player is requesting in terms of movement. Axis blendings are the most complex as you need to ‘position’ all of the animations on a 2d grid. So idle is at 0,0 (no movement), walking is at 0,1 (forwards) and running at 0,2 etc.

                            moving.blend(
			    new pc.BlendAxis()
				.add({
					v: {
						value: -1,
						extent: 1
					},
					h: {
						value: 0,
						extent: 200
					}
				}, motion.blends["Ty@walking_backwards"]
					.loop()
					.motion(true, false, true))
				.add({
					v: {
						value: 0,
						extent: 1
					},
					h: {
						value: 0,
						extent: 1
					}
				}, motion
                                        .blends
                                        .idle
                                        .loop()
                                        .rotation(false, false, false))
				.add({
					v: {
						value: 1,
						extent: 1
					},
					h: {
						value: 0,
						extent: 200
					}
				}, motion.blends["Ty@walking"]
					.loop()
					.motion(true, false, true))
				.add({
					v: {
						value: 2,
						extent: 1
					},
					h: {
						value: 0,
						extent: 200
					}
				}, motion.blends["Ty@running"]
					.loop()
					.motion(true, false, true))

You can see here how we specify a value for v and h which is the centre point of the animation (where it is strongest) and then an extent which is either a number (to make it a half-extent) or a range of min and max values supplied in an object. Next we pass the “blend” that will be used in that position, this object can have functions like .loop() called on it to make it loop, .motion(x,y,z) to say which axes of root motion should be used, .rotation(x,y,z) is also available to control whether the animation rotates the physical object or just the model. By default there is no root motion or rotation. More examples of this for the other states can be seen in the demo code.

We need to tell the blend axis how to interpret the values of v and h - this is done by assigning functions which return the current value.

                              moving.value('v', function () {
					return this.v;
				}.bind(this))
				.value('h', function () {
					return this.h;
				}.bind(this))

In this case just returning properties of the calling script which are numbers v and h;

Then the controls just need to set these variables in response to user input, these are shown a little later.

Next up to handle jumping we can create another state that plays that animation.

		motion
			.stateMachine()
			.state('jump')
			.blend(
				motion.blends["Ty@jump_over"]
				.motion(false, false, true)
			)
			.after(0.87, function() {
				motion.stateMachine().currentState = 'moving';
			})
			.def()
			.blend(0.5);

So we define a new state called ‘jump’ give it a ‘blend’ of the animation and then in order to return to movement when it’s finished we can put a .after(proportion, fn) call in this case setting the state back when the animation is 87% complete. We do this to allow blending which is controlled by .def() in this example (the default blend when going to any other state or .to(state) which is used for fine control. The result of that function call is an object that contains a .blend(time) call to blend the transition or a .snap() function to cause an instantaneous flip.

The final state we need for the demo is clapping but this needs to be on a different layer so that it can be played at the same time as the other animations. To do that we get the next statemachine by calling motion.stateMachine(1) and assign the state to that.

		motion
			.stateMachine(1)
			.mask(motion.mask('Spine', 0.00001, true))
			.state('clapping')
			.blend(motion.blends.clapping.loop())
			.def()
			.blend(0.6);

The other thing to note here is the call to .mask here we pass the result of a motion helper that applies a weight to a part of the body and all children or the inverse of that. Our call here says apply a weight of 0.0001 to the “inverse” of everything that is a child of the spine - in this case that means the legs. So this animation won’t affect leg movement.

We need to pair that up by telling our ‘moving’ state not to move the arms if there’s anything else trying. We can just add that:

		motion
			.stateMachine()
			.mask(motion.mask('Spine', 0.05));

Here saying we’ll let the arms have a weight of 0.05 - note that if nothing else is affecting the arms then they will still move perfectly according to the base animation. Its only when we play ‘clapping’ that they deviate.

Finally here’s the user input being captured in the update call:

	update: function (dt) {
		this.targetV = 0;
		this.targetH = 0;
		if (app.keyboard.isPressed(pc.KEY_W)) {
			this.targetV = 1;
			this.t += dt;
			if (this.t > 5) {
				this.targetV = 2;
			}
		}
		else {

			this.t = 0;
		}
		if (app.keyboard.isPressed(pc.KEY_S)) {
			this.targetV = -1;
		}
		if (app.keyboard.isPressed(pc.KEY_A)) {
			this.targetH = -1;
		}
		if (app.keyboard.isPressed(pc.KEY_D)) {
			this.targetH = 1;
		}
		if (app.keyboard.isPressed(pc.KEY_SPACE)) {
			this.entity.motion.stateMachine().currentState = 'jump';
		}
		if (app.keyboard.isPressed(pc.KEY_C)) {
			this.entity.motion.stateMachine(1).currentState = 'clapping';
		} else {
			this.entity.motion.stateMachine(1).currentState = '';
		}
		this.v = pc.math.lerp(this.v, this.targetV, dt * 9);
		this.h = pc.math.lerp(this.h, this.targetH, dt * 9);
	}

###Notes

This works well with Mixamo animations, less clean animations may not work as well. Animations which lead on a different foot need to be offset (using .offset(0.5) or something similar) to ensure that the legs work properly - mirrored animations like the right turn in the demo will definitely lead on a different foot.

In general blend axes work well when the legs move a similar amount and its not a really good idea to blend strafes with anything.

Any comments or questions, please let me know.

3 Likes

Note that the project contains quite a lot of other files - the necessary ones for the demo are motion.js and test-motion.js

blends can also be cut from a source that combines multiple animations (as some bought models do) to do this use .clone() to make a copy of the blend and .cut(startFrame, endFrame) to extract the required animation.

motion.blends.bigSetOfAnimations.clone().cut(1205, 50) - take 50 frames of animation from position 1205.

You can vary the speed of a blend using .speed(speedFactor)