With fading, the result of applying root motion delta is different from the result of not using root motion visually
Tested versions
Reproducible in any 4.x
System information
Godot v4.3.stable - Windows 10.0.19044 - Vulkan (Forward+) - dedicated NVIDIA GeForce RTX 3080 (NVIDIA; 32.0.15.5612) - AMD Ryzen 9 5950X 16-Core Processor (32 Threads)
Issue description
The root motion accumulator system, while a huge step up from 3.5, is still unable to address crossfading.
The problem
When a character is crossfading from one animation to another, they will often transition in such a way that undesired diagonal or negative movement is created from using the blended root rotation accumulator of the two animations against their blended root motion. Practically speaking, only a root-aligned movement vector needs to be considered.
Allow me to explain.
I have a 90 degree walk start, in two directions.
| Left | Right |
|---|---|
Taking the right animation for instance, because the animation ends with the root motion accumulator facing -X, while my normal walk cycle starts facing -Y (+Z in Godot), what ends up happening is diagonal drift when xfading the two.
To demonstrate this, I have slowed down the start animation with a TimeScale while keeping the walk cycle at normal speed to exaggerate the effect of this drift.
No xfade:
https://github.com/user-attachments/assets/310bcf7d-38bb-48f8-9b5f-323121856774
0.2 xfade:
https://github.com/user-attachments/assets/f0ffa2f5-63e2-4818-a7c9-7eeb49df96d6
Here is how I handle my root motion, for my Auto-Rig Pro character in Blender for a root bone with a Z-up, Y-forward orientation.
root_movement = $AnimationTree.get_root_motion_position() / delta
root_rotation = $AnimationTree.get_root_motion_rotation()
if abs(root_rotation.get_euler().z) > 0:
rotate_y(root_rotation.get_euler().z)
# root direction in animation space
var root_rotation_accumulator = animation_tree.get_root_motion_rotation_accumulator()
# rotating the movement vector to be in root's local space
var root_aligned_movement_vector = -root_movement.rotated(Vector3.UP, -root_rotation_accumulator.get_euler().y)
# rotating vector to be in global space
var vel: Vector3 = global_basis.get_rotation_quaternion() * root_aligned_movement_vector
DebugDraw3D.draw_line(global_position, global_position + vel, Color.RED)
I came up with this independently of the documentation but from my testing should yield an identical result to the documented method.
The solution
- Either correctly interpret, or let us define the forward direction of the root bone in AnimationMixer.
Since the current get_root_motion_position()/get_root_motion_rotation() operates under "global" space,
- Provide a way to get the neutralized root motion position and rotation vectors in the LOCAL space of the root bone, so that crossfading becomes possible without errors. This would mean that for a simple animation like my turn, each frame of get_root_motion_local_position would be largely towards the bone's forward axis, and each get_root_motion_local_rotation would be entirely on the bone's up axis.
Using these "local" values in root motion code would allow for identical results with smooth crossfades. In my mind, this seems to be the most straightforward way to address this, @TokageItLab might have a better idea.
Steps to reproduce
Download the project, play it, press WASD to move at a 90 degree angle from the facing direction and observe the drift. Then, open AnimationTree > move > start and change the fadeout time to 0. Try again, and observe lack of drift.
Minimal reproduction project (MRP)
I remember this matter being pointed out in the past as another issue. The problem is simpler: there is no actual Transform there in root motion delta.
Consider the case of a 90deg rotation without a move, the actual total delta of the root motion is short of 90deg due to the crossfade.
It is the same for translation: if the root motion moves 1m and there is a crossfade, the total actual delta will be less than 1m.
I wonder if other game engines handle it correctly. If so, there may be a solution by making some changes to the delta calculation process, but I don't know, I haven't researched it in detail. It would be helpful to make a survey comparing the root motion system in several engine/environments.
In my opinion, we probably need to extract the delta after the animation blends, but we need to figure out a way to handle loop animations correctly; there is actually a shift in position due to blending (in the case with with normal animations), so we need to take into account the delta after blending.
Simply put, in the case where a loop never occurs, we can use accumulator's delta instead of root motion to get consistent results between normal animation vs root motion, but the real case is that the loop is there almost always.
Using these "local" values in root motion code would allow for identical results with smooth crossfades. In my mind, this seems to be the most straightforward way to address this
If you simply mean that you don't want the rotation to be handled by the root motion - If you want local transform for the position, then I guess you just need to swap the order of the multiplications with rotation and position.
Consider the case of a 90deg rotation without a move, the actual total delta of the root motion is short of 90deg due to the crossfade.
It is the same for translation: if the root motion moves 1m and there is a crossfade, the total actual delta will be less than 1m.
Even with duration accounted for and a perfect handoff via xfade, we would still get drift. Consider this:
Even though the blend should treat the crossfade region as the exact same thing while keeping movement vector intact, it doesn't, because:
with get_root_motion returning position and rotation in world space:
- animation A's root rotation accumulator points to -X (is at -90°) while animation B's root rotation accumulator points to +Z (is at 0°), and
- A's root motion position delta returns (-0.1, 0, 0) and B's returns (0,0,0.1) for the entire duration of the crossfade, with our only way of neutralizing these two being the already skewed (blended) root rotation accumulator from 1.
So, we would move diagonally, between +Z and -X, during the crossfade, when we should just keep moving along -X. That's the issue.
https://github.com/user-attachments/assets/f0ffa2f5-63e2-4818-a7c9-7eeb49df96d6
Accumulator was a step in the right direction but what's really needed is get_root_motion_position or an adjacent get_root_motion_local_position returning a movement delta that treats the root bone's axes as its basis.
I see the essence of the problem now.
Node3D's transform performs rotation after position as specification (also consistent with glTF), but CharacterBody's velocity performs position after rotation, so it is necessary to get the local position by accumulator.
However, if there is fading, the "sum of the root motion delta" and "the root motion accumulator delta" will be different, this is exactly what the problem is; It doesn't allow to calculate the correct local position.
I am not sure if this problem can be solved by simply calculating the delta of the root motion position in local space before blending, taking into account the rotation, but I will investigate later.
I am not sure if this problem can be solved by simply calculating the delta of the root motion position in local space before blending, taking into account the rotation, but I will investigate later.
I appreciate it. Please test against my minimal reproduction project as this problem is a little tricky to highlight in ordinary use.
I sent https://github.com/godotengine/godot/pull/99394, please check if it helps and calculates correctly.
I tested it, works great. Thank you for looking into it.