Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setting scale X or Y (not both) to negative value on Skeleton2D breaks Modifications like SkeletonModification2DLookAt and SkeletonModification2DTwoBoneIK #80252

Open
hsandt opened this issue Aug 4, 2023 · 7 comments · May be fixed by #81051 or #83330

Comments

@hsandt
Copy link
Contributor

hsandt commented Aug 4, 2023

Godot version

v4.1.stable.official [9704596]

System information

Godot v4.1.stable - Ubuntu 22.04.2 LTS 22.04 - Vulkan (Mobile) - dedicated NVIDIA GeForce GTX 860M (nvidia; 535.54.03) - Intel(R) Core(TM) i7-4710HQ CPU @ 2.50GHz (8 Threads)

Issue description

Context

I'm working with a 2D platformer character who flips around horizontally when going left, using a full scale X = -1 on a parent containing all visual nodes, including the Skeleton2D. The Skeleton2D uses a 2-bone IK modification. I noticed that when setting scale X = -1 on the Skeleton 2D (whether the IK target is also scaled or not), the bones didn't behave as expected.

General issue

When a Skeleton2D (or some parent of it) is scaled on X or Y (but not both) with a negative value, modifications such as SkeletonModification2DLookAt and SkeletonModification2DTwoBoneIK do not behave as expected: bones go the opposite way of intended, as if moving away from the target.

Example with an actual character using 2-bone IK on arms:

Normal pose, arm-hand using 2-bone IK toward two targets (red crosses):
image

Scale X = -1, including IK targets to flip them so they stay near corresponding bones:
image

Scale X = -1, excluding IK targets:
image

Note that three phenomena occur:

  1. The bone root positions are flipped, but the bones keep their angle if auto_calculate_length_and_angle is false, using the fixed Bone Angle; or keep some relative angle else, giving the impression of changing the pose instead of doing a proper flip. It looks like the legs are broken too. But this is essentially an impression (just ignore the head and feet pointing to the right)
  2. The targets may or may not be flipped (in the last picture, they are not). When not flipped, the hands should not be pointing at the right target, and cross each other's directions toward the bottom, but not go up as they are doing now.
  3. The actual bug causing the hands to go up, "evading" the targets.

Another smaller example that you'll find in the MWE attached:

Normal pose (uses 2-bone IK + Look At for another bone):
image

Scale X = -1:
image

Scale Y = -1:
image

This time, LookAt is easy t understand, 2-bone IK much less as the bones shrink on themselves.

Steps to reproduce

  1. Open MWE which has the simple skeleton shown above setup
  2. Open demo.tscn and select Skeleton2D
  3. Set Scale X = -1. Observe
  4. Set Scale Y = -1. Observe

Minimal reproduction project

Godot 4.1 - Scaling skeleton X=-1 causes 2BoneIK to avoid target.zip

@thiagola92
Copy link
Contributor

thiagola92 commented Aug 13, 2023

I think that most problems have to do with using transform and not restoring rotation and scale.

It's not possible to represent negative X scale with transforms, it needs to restore this information from the local scale/rotation and not from the transform (I couldn't find how Node2D normally does that restoration).

So when the Bone2D sets the transform, it loses the true scale and rotation. Next time it tries to load from its transform, the Y scale and the rotation will be flipped (saving the scene would be an example of loading from the transform again).

set_transform(cache_transform);

set_transform(cache_transform);

Screencast.from.2023-08-13.09-23-19.webm

I did some testing with caching the scale and rotation (my own way because i don't understand how Node2D restore this information).

Screencast.from.2023-08-13.09-24-17.webm

Note that after creating a SkeletonModificationStack2D it will always send the transform to the MeshStorage. I think that this is when it tries to update the bone every frame with the information from transform (which is not good because transform can't restore true scale and rotation).

https://github.com/godotengine/godot/blob/master/scene/2d/skeleton_2d.cpp#L618

As is always flipping the Y scale and the rotation, this probably cause most of the problems with SkeletonModification2DLookAt and SkeletonModification2DTwoBoneIK.

Note: I could be wrong 🤣

@AThousandShips
Copy link
Member

See also:

@thiagola92
Copy link
Contributor

thiagola92 commented Aug 20, 2023

Note: I could be wrong 🤣

And I was wrong. I started looking at the math behind SkeletonModification2DTwoBoneIK

On range

joint 1 = arctan - angle 0

joint_one_bone->set_global_rotation(angle_atan - angle_0 - joint_one_bone->get_bone_angle());

And my guess is that when you scale X by -1 you should stop subtracting arctangent by the internal angle and start adding it.

joint 1 = arctan + angle 0

joint_one_bone->set_global_rotation(angle_atan + angle_0 + joint_one_bone->get_bone_angle());

Out of range

joint_one_bone->set_global_rotation(angle_atan - joint_one_bone->get_bone_angle());
joint_two_bone->set_global_rotation(angle_atan - joint_two_bone->get_bone_angle());

After scaling X by -1, bone angles start rotating to the opposite direction.

joint_one_bone->set_global_rotation(angle_atan + joint_one_bone->get_bone_angle());
joint_two_bone->set_global_rotation(angle_atan + joint_two_bone->get_bone_angle());

Bug

float bone_one_length = joint_one_bone->get_length() * MIN(joint_one_bone->get_global_scale().x, joint_one_bone->get_global_scale().y);
float bone_two_length = joint_two_bone->get_length() * MIN(joint_two_bone->get_global_scale().x, joint_two_bone->get_global_scale().y);

It should be using absolute values. Otherwise:

  • (1, 100) will create a bone length scaled by 1
  • (100, 1) will create a bone length scaled by 1
  • (1, -100) will create a bone length scaled by -100
float bone_one_length = joint_one_bone->get_length() * MIN(ABS(joint_one_bone->get_global_scale().x), ABS(joint_one_bone->get_global_scale().y));
float bone_two_length = joint_two_bone->get_length() * MIN(ABS(joint_two_bone->get_global_scale().x), ABS(joint_two_bone->get_global_scale().y));

Tests

I only did manual tests, so I could be wrong (again).

Screencast.from.2023-08-20.08-35-40.webm

code:: master...thiagola92:godot:fix_twoboneik_with_negative_scale

@hsandt
Copy link
Contributor Author

hsandt commented Aug 23, 2023

Looks good to me, you can open a PR to start getting reviews from other devs.

Side note: wow, checked the reference links above the modified code and found a nice article and blog! Also found a typo // Adopted from the links below: -> Adapted, I don't know what Godot's policy is on these small things, you can probably fix it on the go and just mention it in the commit message.

@thiagola92
Copy link
Contributor

thiagola92 commented Aug 23, 2023

Yes! This article and blog were very helpful!
I will fix the typo, thanks.

I still have to look at the Gizmo, not sure if was suppose to rotate this way after scaling. 😆

Screencast.from.2023-08-23.13-15-23.webm

Edit: Fixed, i will create PR

MinntLeaf added a commit to chrisl8/crater-manipulator that referenced this issue Jan 8, 2024
Flipping collision shapes is not supported, so the sub components must be flipped. However Godot skeletons IK's break when flipped. So character flipping is not possible right now without creating a separate player skeleton and linked components for each direction.

I do not know if this is intended behavior or a bug, but it is documented here:
godotengine/godot#75224
godotengine/godot#80252
@874808039
Copy link

Can this fix be added to version 4.3?

@lethiandev
Copy link
Contributor

lethiandev commented Feb 23, 2024

If someone's interested in a temporary solution, I have created a script to do the two bone IK calculations, respecting the transform of the skeleton. It's still quite limited, as the scale values can be only 1 or -1, as neither bone length is "scaled" nor the distance between the bone origin and the target node.

I based my implementation on
https://www.ryanjuckett.com/analytic-two-bone-ik-in-2d/

In summary - with some tweaks I managed to project the target position onto the bone's local space, this allows to compensate the scale and rotation of the bone's parent, now scaling and rotating the skeleton should work fine (works for me at least).

twoboneik.webm
extends Node

@export_node_path("Node2D")
var target_node_path = NodePath()

@export var flip_bend_direction = false
@export var joint_one_bone_index = -1
@export var joint_two_bone_index = -1

var _angle_a = 0.0
var _angle_b = 0.0


func _process(delta: float) -> void:
	_update_two_bone_ik_angles()


func _update_two_bone_ik_angles():
	assert(joint_one_bone_index != -1)
	assert(joint_two_bone_index != -1)
	
	if target_node_path.is_empty():
		return
	
	var target = get_node(target_node_path) as Node2D
	var bone_a = get_parent().get_bone(joint_one_bone_index)
	var bone_b = get_parent().get_bone(joint_two_bone_index)
	
	var bone_a_len = bone_a.get_length()
	var bone_b_len = bone_b.get_length()
	
	var sin_angle2 = 0.0
	var cos_angle2 = 1.0
	
	_angle_b = 0.0
	
	var cos_angle2_denom = 2.0 * bone_a_len * bone_b_len
	if not is_zero_approx(cos_angle2_denom):
		var target_len_sqr = _distance_squared_between(bone_a, target)
		var bone_a_len_sqr = bone_a_len * bone_a_len
		var bone_b_len_sqr = bone_b_len * bone_b_len
		
		cos_angle2 = (target_len_sqr - bone_a_len_sqr - bone_b_len_sqr) / cos_angle2_denom
		cos_angle2 = clamp(cos_angle2, -1.0, 1.0);
		
		_angle_b = acos(cos_angle2)
		if flip_bend_direction:
			_angle_b = -_angle_b
		
		sin_angle2 = sin(_angle_b)
	
	var tri_adjacent = bone_a_len + bone_b_len * cos_angle2
	var tri_opposite = bone_b_len * sin_angle2
	
	var xform_inv = bone_a.get_parent().global_transform.affine_inverse()
	var target_pos = xform_inv * target.global_position - bone_a.position
	
	var tan_y = target_pos.y * tri_adjacent - target_pos.x * tri_opposite
	var tan_x = target_pos.x * tri_adjacent + target_pos.y * tri_opposite
	_angle_a = atan2(tan_y, tan_x)
	
	var bone_a_angle = bone_a.get_bone_angle()
	var bone_b_angle = bone_b.get_bone_angle()
	bone_a.rotation = _angle_a - bone_a_angle
	bone_b.rotation = _angle_b - angle_difference(bone_a_angle, bone_b_angle)


func _distance_squared_between(node_a: Node2D, node_b: Node2D) -> float:
	return node_a.global_position.distance_squared_to(node_b.global_position)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
6 participants