Jump to content

Looking for clarity about LerpLayer()


photo

Recommended Posts

Posted (edited)

Hello Unigine devs.

I am learning animation for the ObjectMeshSkinned class.  I do not fully understand LerpLayer().  I have two questions:

1.  For animation blending, I see some sample code using SetLayer() to update layer weight, and other sample code using LerpLayer() for layer weighted interpolation between two animations.  Both examples are setting weights.  What is the difference/tradeoffs between blending using SetLayer() and using LerpLayer()?  Are they both the same?

2. The documentation says the first argument for LerpLayer() is the layer the result will be stored to.  I do not understand how this works.

From the C# examples, AnimationInterpolation.cs has the following code:

// set current frame for first and second animation
meshSkinned.SetFrame((int)LAYERS.FIRST_ANIMATION, currentTime * firstAnimationSpeed);
meshSkinned.SetFrame((int)LAYERS.SECOND_ANIMATION, currentTime * secondAnimationSpeed);

currentTime += Game.IFps;

// interpolate between layers
meshSkinned.LerpLayer((int)LAYERS.FIRST_ANIMATION, (int)LAYERS.FIRST_ANIMATION, (int)LAYERS.SECOND_ANIMATION, weight);

From the C# examples, AnimationPartialInterpolation.cs has the following code:

// set current frame for first and second animation
meshSkinned.SetFrame((int)LAYERS.FIRST_ANIMATION, currentTime * firstAnimationSpeed);
meshSkinned.SetFrame((int)LAYERS.SECOND_ANIMATION, currentTime * secondAnimationSpeed);

currentTime += Game.IFps;

// interpolate between layers
meshSkinned.LerpLayer((int)LAYERS.SECOND_ANIMATION, (int)LAYERS.FIRST_ANIMATION, (int)LAYERS.SECOND_ANIMATION, weight);

They are almost identical except in the top snippet, LerpLayer() is called with LAYERS.FIRST_ANIMATION as the destination layer.  In the second snippet, LerpLayer() is called with LAYERS.SECOND_ANIMATION as the destination layer.  I don't understand why the first parameter is different between the two. 

Also, how can Update() keep calling LerpLayer() if every frame it is updating the animation in the destination layer? I guess I need to have a better understanding of the LerpLayer() is doing under the hood.  Any details would be appreciated.

Edited by webnetweaver
Posted

Hello.

Layers

When animating an ObjectMeshSkinned, all the work happens on its layers. You can set any number of layers, and also assign some animations to parts of them. The layer inside this object should be thought of as a variable that stores the pose of the skeleton. This pose can be retrieved from the animation using SetLayerFrame for the specified frame. Then you can work with poses through several functions: LerpLayer, CopyLayer, ImportLayer, InverseLayer and MulLayer.

For example, if you think of layers as poses, the interpolation might look like this in pseudocode:

SkeletonPose idlePose = skinned.GetPose(idleAnimPath, idleFrame);
SkeletonPose walkPose = skinned.GetPose(walkAnimPath, walkFrame);
SkeletonPose blendPose = blend(idlePose, walkPose, weight);
skinned.SetPose(blendPose);

By default, when created, ObjectMeshSkinned has only one active layer, the pose of which is used for animation. That is, we can write the result of our operations on poses into it to see the result on the object. Let's repeat the example above. We will also have two animations: idle and walk.

void Init()
{
	// to get poses from two animations we need two layers
	skinned.NumLayers = 2;

	// we set our animations on separate layers
	skinned.SetLayerAnimationFilePath(0, idleAnimPath);
	skinned.SetLayerAnimationFilePath(1, walkAnimPath);
}

void Update()
{
	// with each frame update we get new poses
	skinned.SetLayerFrame(0, currentTime * idleAnimSpeed);
	skinned.SetLayerFrame(1, currentTime * walkAnimSpeed);

	// now we need to blend these two poses
	// but to see the result on the object we need to write the blending result on the zero layer
	// that is, we will simply rewrite the previously calculated idle pose
	skinned.LerpLayer(0, 0, 1, weight);
}

All other functions for layers and operations on them can be perceived in a similar way.

Internal blending

ObjectMeshSkinned itself can blend poses across all layers. For a layer to be included in the final blend, it must be enabled and have a non-zero weight. These values can be changed using SetLayer, SetLayerEnabled and SetLayerWeight. When blending, an arithmetic weighted average is used, that is, all weights are normalized, and then all components are added together by multiplying by this weight. For example, for the zero bone position: FINAL_P0 = LAYER_0_P0 x LAYER_0_WEIGHT + LAYER_1_P0 x LAYER_1_WEIGHT + ... + LAYER_N_P0 x LAYER_N_WEIGHT. A similar approach is used for scaling and a slightly different one for rotations, but for working at the layer level this is not critical.

The example above can be repeated as follows:

void Init()
{
	// to get poses from two animations we need two layers
	skinned.NumLayers = 2;

	// we enable two layers, but set the maximum weight for the idle animation
	skinned.SetLayer(0, true, 1.0f);
	skinned.SetLayer(1, true, 0.0f);

	// we set our animations on separate layers
	skinned.SetLayerAnimationFilePath(0, idleAnimPath);
	skinned.SetLayerAnimationFilePath(1, walkAnimPath);
}

void Update()
{
	// with each frame update we get new poses
	skinned.SetLayerFrame(0, currentTime * idleAnimSpeed);
	skinned.SetLayerFrame(1, currentTime * walkAnimSpeed);

	// and then we just change the weights on the layers
	skinned.SetLayerWeight(0, 1.0f - weight);
	skinned.SetLayerWeight(1, weight);
}

Custom blending

Automatic blending is only suitable for simple use cases as it provides little control. To implement partial bone blending, additive animation, and so on, you have to implement your own logic. To work with individual bones on layers, there are methods SetLayerBoneTransform / GetLayerBoneTransform, as well as analogs for individual components: position, rotation and scale. With their help, you can make masks for bones, and then work only with them in a cycle.

Comparison of examples

Now it might be a little clearer the difference between the two examples AnimationInterpolation and AnimationPartialInterpolation.

AnimationInterpolation:

// we get poses from animations on two separate layers
meshSkinned.SetFrame((int)LAYERS.FIRST_ANIMATION, currentTime * firstAnimationSpeed);
meshSkinned.SetFrame((int)LAYERS.SECOND_ANIMATION, currentTime * secondAnimationSpeed);

currentTime += Game.IFps;

// then we overwrite the pose of the first animation with the result of the blend
meshSkinned.LerpLayer((int)LAYERS.FIRST_ANIMATION, (int)LAYERS.FIRST_ANIMATION, (int)LAYERS.SECOND_ANIMATION, weight);

AnimationPartialInterpolation:

// we get poses from animations on two separate layers
meshSkinned.SetFrame((int)LAYERS.FIRST_ANIMATION, currentTime * firstAnimationSpeed);
meshSkinned.SetFrame((int)LAYERS.SECOND_ANIMATION, currentTime * secondAnimationSpeed);

currentTime += Game.IFps;

// next we want to add a second animation to the first animation, but for a limited set of bones
// now on the zero layer we have the finished pose of the first animation
// on the first layer we will write the result of blending the first and second poses
// and then we add the result of blending according to the configured mask
// pseudocode:
// SkeletonPose firstPose = skinned.GetPose(firstAnimPath, firstAnimFrame);
// SkeletonPose secondPose = skinned.GetPose(secondAnimPath, secondAnimFrame);
// secondPose = blend(firstPose, secondPose, weight);
// firstPose = blend(firstPose, secondPose, partialBoneMask);
// skinned.SetPose(firstPose);

// we write the result of blending on the first layer
meshSkinned.LerpLayer((int)LAYERS.SECOND_ANIMATION, (int)LAYERS.FIRST_ANIMATION, (int)LAYERS.SECOND_ANIMATION, weight);

// we transfer blended bones to the zero layer using the specified mask
if (bonesNumbers != null)
	foreach (int bone in bonesNumbers)
		meshSkinned.SetLayerBoneTransform((int)LAYERS.FIRST_ANIMATION, bone, meshSkinned.GetLayerBoneTransform((int)LAYERS.SECOND_ANIMATION, bone));

 

  • Like 2
Posted (edited)
On 8/11/2025 at 5:13 AM, karpych11 said:

Hello.

Layers

When animating an ObjectMeshSkinned, all the work happens on its layers. You can set any number of layers, and also assign some animations to parts of them. The layer inside this object should be thought of as a variable that stores the pose of the skeleton. This pose can be retrieved from the animation using SetLayerFrame for the specified frame. Then you can work with poses through several functions: LerpLayer, CopyLayer, ImportLayer, InverseLayer and MulLayer.

For example, if you think of layers as poses, the interpolation might look like this in pseudocode:

SkeletonPose idlePose = skinned.GetPose(idleAnimPath, idleFrame);
SkeletonPose walkPose = skinned.GetPose(walkAnimPath, walkFrame);
SkeletonPose blendPose = blend(idlePose, walkPose, weight);
skinned.SetPose(blendPose);

By default, when created, ObjectMeshSkinned has only one active layer, the pose of which is used for animation. That is, we can write the result of our operations on poses into it to see the result on the object. Let's repeat the example above. We will also have two animations: idle and walk.

void Init()
{
	// to get poses from two animations we need two layers
	skinned.NumLayers = 2;

	// we set our animations on separate layers
	skinned.SetLayerAnimationFilePath(0, idleAnimPath);
	skinned.SetLayerAnimationFilePath(1, walkAnimPath);
}

void Update()
{
	// with each frame update we get new poses
	skinned.SetLayerFrame(0, currentTime * idleAnimSpeed);
	skinned.SetLayerFrame(1, currentTime * walkAnimSpeed);

	// now we need to blend these two poses
	// but to see the result on the object we need to write the blending result on the zero layer
	// that is, we will simply rewrite the previously calculated idle pose
	skinned.LerpLayer(0, 0, 1, weight);
}

All other functions for layers and operations on them can be perceived in a similar way.

Internal blending

ObjectMeshSkinned itself can blend poses across all layers. For a layer to be included in the final blend, it must be enabled and have a non-zero weight. These values can be changed using SetLayer, SetLayerEnabled and SetLayerWeight. When blending, an arithmetic weighted average is used, that is, all weights are normalized, and then all components are added together by multiplying by this weight. For example, for the zero bone position: FINAL_P0 = LAYER_0_P0 x LAYER_0_WEIGHT + LAYER_1_P0 x LAYER_1_WEIGHT + ... + LAYER_N_P0 x LAYER_N_WEIGHT. A similar approach is used for scaling and a slightly different one for rotations, but for working at the layer level this is not critical.

The example above can be repeated as follows:

void Init()
{
	// to get poses from two animations we need two layers
	skinned.NumLayers = 2;

	// we enable two layers, but set the maximum weight for the idle animation
	skinned.SetLayer(0, true, 1.0f);
	skinned.SetLayer(1, true, 0.0f);

	// we set our animations on separate layers
	skinned.SetLayerAnimationFilePath(0, idleAnimPath);
	skinned.SetLayerAnimationFilePath(1, walkAnimPath);
}

void Update()
{
	// with each frame update we get new poses
	skinned.SetLayerFrame(0, currentTime * idleAnimSpeed);
	skinned.SetLayerFrame(1, currentTime * walkAnimSpeed);

	// and then we just change the weights on the layers
	skinned.SetLayerWeight(0, 1.0f - weight);
	skinned.SetLayerWeight(1, weight);
}

Custom blending

Automatic blending is only suitable for simple use cases as it provides little control. To implement partial bone blending, additive animation, and so on, you have to implement your own logic. To work with individual bones on layers, there are methods SetLayerBoneTransform / GetLayerBoneTransform, as well as analogs for individual components: position, rotation and scale. With their help, you can make masks for bones, and then work only with them in a cycle.

Comparison of examples

Now it might be a little clearer the difference between the two examples AnimationInterpolation and AnimationPartialInterpolation.

AnimationInterpolation:

// we get poses from animations on two separate layers
meshSkinned.SetFrame((int)LAYERS.FIRST_ANIMATION, currentTime * firstAnimationSpeed);
meshSkinned.SetFrame((int)LAYERS.SECOND_ANIMATION, currentTime * secondAnimationSpeed);

currentTime += Game.IFps;

// then we overwrite the pose of the first animation with the result of the blend
meshSkinned.LerpLayer((int)LAYERS.FIRST_ANIMATION, (int)LAYERS.FIRST_ANIMATION, (int)LAYERS.SECOND_ANIMATION, weight);

AnimationPartialInterpolation:

// we get poses from animations on two separate layers
meshSkinned.SetFrame((int)LAYERS.FIRST_ANIMATION, currentTime * firstAnimationSpeed);
meshSkinned.SetFrame((int)LAYERS.SECOND_ANIMATION, currentTime * secondAnimationSpeed);

currentTime += Game.IFps;

// next we want to add a second animation to the first animation, but for a limited set of bones
// now on the zero layer we have the finished pose of the first animation
// on the first layer we will write the result of blending the first and second poses
// and then we add the result of blending according to the configured mask
// pseudocode:
// SkeletonPose firstPose = skinned.GetPose(firstAnimPath, firstAnimFrame);
// SkeletonPose secondPose = skinned.GetPose(secondAnimPath, secondAnimFrame);
// secondPose = blend(firstPose, secondPose, weight);
// firstPose = blend(firstPose, secondPose, partialBoneMask);
// skinned.SetPose(firstPose);

// we write the result of blending on the first layer
meshSkinned.LerpLayer((int)LAYERS.SECOND_ANIMATION, (int)LAYERS.FIRST_ANIMATION, (int)LAYERS.SECOND_ANIMATION, weight);

// we transfer blended bones to the zero layer using the specified mask
if (bonesNumbers != null)
	foreach (int bone in bonesNumbers)
		meshSkinned.SetLayerBoneTransform((int)LAYERS.FIRST_ANIMATION, bone, meshSkinned.GetLayerBoneTransform((int)LAYERS.SECOND_ANIMATION, bone));

 

Thank you for the thorough explanation.  I understand now how animation blending works.  Here's my takeaways from your explanation.  Let me know if anything I say is wrong.

There's two general ways to go about blending animations; Internal blending and Custom blending.  Theyre different approaches so theyre not the same.

For internal blending, a call to setLayer() is made for every layer used in blending.  setLayer() not only sets the animation and weight, but it also indicates that the layer should be considered in the final blend if it's weight is greater than 0.  During each Update(), the code calls setLayerWeight(), or setLayer(), to update weights for each layer used in the final blend.  Internal blending is simpler to use but not as flexible as Custom blending.

For Custom blending, setLayer() isnt used.  ObjectMeshSkinned always starts with 1 base layer which can be used to hold the final blended pose. There's no need to call setLayer() because this base layer is automatically considered.  Additional layers can be used for performing layer operations but not necessaily a part of the final blend.  During each update, instead of updating weights, in Custom blending layers are manipulated using layer operations like LerpLayer(), CopyLayer(), MulLayer() etc.  The final result is stored in the base layer.  Custom blending is more complex and more flexible.

For both methods, during each Update(), setFrame() is used to progress the animation in the layer.  But it not only progresses the animation, it resets whatever was stored in the layer to a clean animation pose.  This means setFrame() should be called before layer blending operations when using Custom blending.  For Custom blending, Update() can keep calling LerpLayer() each frame because setFrame() is setting the layer's animation data back to a non-blended animation pose.

In the AnimationPartialInterpolation example, the reason LerpLayer is storing the result in the second layer is because the second layer isn't being considered in the final pose, it's just an auxiliary layer used for blending.  The final step after that line is where the code is copying individual bone transforms from the second layer to the base layer to be a part of the final pose.

I think I have a much better understanding now.  

 

Edited by webnetweaver
Posted

Yes, everything described above is correct. But I will add a few details.

I wouldn't say that internal and custom blending are very different approaches. Internal blending can be easily reproduced in custom logic using only the LerpLayer function. The main thing is not to forget to leave only the zero layer enabled so that the others do not affect the internal blending stage. It looks like this:

float totalWeight = 0.0f;
for (int i = 0; i < meshSkinned.NumLayers; i++)
{
	float layerWeight = meshSkinned.GetLayerWeight(i);
	if (layerWeight < MathLib.EPSILON)
		continue;

	totalWeight += layerWeight;
	meshSkinned.LerpLayer(0, 0, i, layerWeight / totalWeight);
}

SetLayer, SetLayerEnabled, and SetLayerWeight can be called at any time, not just during initialization. Depending on the logic, layers can be included or excluded from internal blending. Also, these values can be changed on demand, not every frame.

Same thing with setFrame. You can call not every frame, but only when you need to put a new pose on the layer. For example, only at initialization you can prepare a pose for calculating additive animation, and then use it when updating the frame. It is also possible to prepare different static poses for the effects of inclining, squatting, etc.

We can look at a more complex example of walking animation. We have 5 animations:  idle, walk forward, walk backward, walk left and walk right. Let's assume that they contain the same number of frames and the phases of the leg movements match, so as not to add animation synchronization to the example. We use only internal blending:

vec2 direction = new vec2();
bool isDirectionChanged = false;
List<float> weights = new List<float>();

void Init()
{
	// ...
	
	meshSkinned.NumLayers = (int)LAYERS.COUNT;
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.IDLE_ANIMATION, idleAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.FORWARD_ANIMATION, forwardAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.BACKWARD_ANIMATION, backwardAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.LEFT_ANIMATION, leftAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.RIGHT_ANIMATION, rightAnimation);

	weights.Add(1.0f);
	for (int i = 1; i < meshSkinned.NumLayers; i++)
		weights.Add(0.0f);

	for (int i = 0; i < meshSkinned.NumLayers; i++)
		meshSkinned.SetLayer(i, true, weights[i]);
		
	// ...
}

void Update()
{
	// ...

	for (int i = 0; i < meshSkinned.NumLayers; i++)
		meshSkinned.SetLayerFrame(i, currentTime * animSpeed);

	if (isDirectionChanged)
	{
		isDirectionChanged = false;
		UpdateWeights(direction, ref weights);

		for (int i = 0; i < meshSkinned.NumLayers; i++)
			meshSkinned.SetLayerWeight(i, weights[i]);
	}
		
	// ...
}

And walking will work completely. But, for example, if we want to add animation with a torch to the upper part, we will have to make custom logic.

vec2 direction = new vec2();
bool isDirectionChanged = false;
List<float> weights = new List<float>();

void Init()
{
	meshSkinned.NumLayers = (int)LAYERS.COUNT;
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.IDLE_ANIMATION, idleAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.FORWARD_ANIMATION, forwardAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.BACKWARD_ANIMATION, backwardAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.LEFT_ANIMATION, leftAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.RIGHT_ANIMATION, rightAnimation);
	meshSkinned.SetLayerAnimationFilePath((int)LAYERS.TORCH_ANIMATION, torchAnimation);

	weights.Add(1.0f);
	for (int i = (int)LAYERS.FORWARD_ANIMATION; i <= (int)LAYERS.RIGHT_ANIMATION; i++)
		weights.Add(0.0f);
}

void Update()
{
	for (int i = 0; i < meshSkinned.NumLayers; i++)
		meshSkinned.SetLayerFrame(i, currentTime * animSpeed);

	if (isDirectionChanged)
	{
		isDirectionChanged = false;
		UpdateWeights(direction, ref weights);
	}

	float totalWeight = 0.0f;
	for (int i = (int)LAYERS.IDLE_ANIMATION; i <= (int)LAYERS.RIGHT_ANIMATION; i++)
	{
		if (weights[i] < MathLib.EPSILON)
			continue;

		totalWeight += weights[i];
		meshSkinned.LerpLayer((int)LAYERS.IDLE_ANIMATION, (int)LAYERS.IDLE_ANIMATION, i, weights[i] / totalWeight);
	}

	foreach (int bone in upperBodyBones)
		meshSkinned.SetLayerBoneTransform((int)LAYERS.IDLE_ANIMATION, bone, meshSkinned.GetLayerBoneTransform((int)LAYERS.TORCH_ANIMATION, bone));
}

Yes, it is also possible to combine these two approaches. For example, enable the first three layers for internal blending of poses for idle, walking and running. And these poses themselves are calculated on additional disabled layers. These can be different variations for one action, which are somehow mixed and prepared for the first three layers.

  • Like 1
×
×
  • Create New...