Is it currently possible to implement ground/IK constraints as can be done in Unity with the SkeletonUtilityConstraint? (see: Spine Skeleton Utility Ground Constraint)

The linked YouTube video demonstrates the exact functionality I want to replicate in Godot. I've tried a couple of different methods using SpineBoneNode and SpineBone but have so far come up empty.

Related Discussions
...

@geowal-wondeluxe Please note that the SkeletonUtilityGroundConstraint in Unity basically just modifies the position of a bone (see the code here) which happens to be an IK-target bone in the skeleton's setup. The whole IK setup is done in Spine. In Unity the bone's position is overridden every frame after the animation has been applied if it's below the ground plane, by doing some raycasts (or sphere-casts).

There is an example scene in the Godot examples project called 11-bone-node that does exactly what you want: drive an IK chain by modifying the target bone position based on raycasting with the ground.

Thanks @Harald and @Mario, but unfortunately this wasn't a lot of help to me. I already understood how the Unity version worked. And the difference between 11-bone-node in the example project and the Unity example is a SpineBoneNode in Drive mode completely overwrites the animation data in Godot. For a walk cycle, we still need the animation data. However, with some further digging into the Spine Godot source I was able to figure out a solution. In my opinion, this could be made simpler with an addition to SpineBoneNode.

I'll walk through my solution in case anyone else stumbles onto this thread looking to do the same thing, then I'll highlight what changes I think would help.

The key to implementing this is knowing that SpineBoneNode must have bone_mode set to Follow, not Drive as is demonstrated in the example, and that the associated SpineBone must have its transform updated to match.

Code

I've created 3 scripts/classes:

  • bone_constraint.gd
  • foot_constraint.gd
  • ground_constraints.gd

bone_constraint.gd

This is a base class for SpineBoneNodes that act as constraints. All this class does is wrap a reference to the associated SpineBone, and provide a shortcut method for updating the bone's transform to match that of the node.

class_name BoneConstraint extends SpineBoneNode


@export var bone_name: String


var bone: SpineBone = null


func _ready():
	var parent: Node = get_parent()
	var spine_sprite: SpineSprite = null

	while (parent):
		spine_sprite = parent as SpineSprite

		if spine_sprite:
			bone = spine_sprite.get_skeleton().find_bone(bone_name)
			break

		parent = parent.get_parent()


func apply_transform():
	bone.set_global_transform(global_transform)

foot_constraint.gd

This class extends BoneConstraint and provides properties specific to a foot that may be grounded, similar to SkeletonUtilityGroundConstraint in Unity. On _physics_process it will perform a raycast to determine the ground position and angle for bone, and also supplies a method to set the position and rotation to the ground.

The raycast is performed separately in _physics_process, as Godot documentation states that this is the only safe time to access PhysicsDirectSpaceState2D.

class_name FootConstraint extends BoneConstraint


@export var ray_offset: Vector2
@export var ray_vector: Vector2
@export var position_offset: Vector2
@export var rotation_offset_degrees: float

var _ray_from: Vector2
var _ray_to: Vector2
var _is_colliding: bool
var _collision_position: Vector2
var _collision_normal: Vector2


func _physics_process(_delta: float):
	_ray_from = global_position + ray_offset
	_ray_to = _ray_from + ray_vector

	var space_state: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
	var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(_ray_from, _ray_to)
	var result: Dictionary = space_state.intersect_ray(query)

	if result:
		_is_colliding = true
		_collision_position = result.position
		_collision_normal = result.normal
	else:
		_is_colliding = false


func update_ground_transform():
	if _is_colliding:
		# global_position = _collision_position + position_offset # Causes feet to get stuck in position.
		global_position = Vector2(global_position.x + position_offset.x, _collision_position.y + position_offset.y)
		global_rotation = Vector2.UP.angle_to(_collision_normal) + deg_to_rad(rotation_offset_degrees)

ground_constraints.gd

This is the main class that drives the constraints. It's a separate Node that should be a child of the SpineSprite. It modifies the bone/foot constraints upon receiving the SpineSprite's world_transforms_changed signal.

My example exposes a foot_constraints array, that should be populated with the FootConstraints of the character, and an anchor_constraint which represents the body bone that adjusts the character's hips/waist to be the appropriate height above the ground.

It's probably also worth noting, that this could easily be implemented inside a script that extends SpineSprite. I've chosen to make it a separate Node, as in practice, many users will have a character controller script attached to their SpineSprite, and having a separate Node separates the functionality.

class_name GroundConstraints extends Node2D


@export var spine_sprite: SpineSprite
@export var anchor_constraint: BoneConstraint
@export var foot_constraints: Array[FootConstraint]
@export var anchor_offset: float


func _ready():
	spine_sprite.connect("world_transforms_changed", _on_world_transforms_changed)


func _on_world_transforms_changed(_sprite: SpineSprite):
	var ground_y: float = global_position.y

	for foot_constraint in foot_constraints:
		foot_constraint.update_ground_transform()
		foot_constraint.apply_transform()
		ground_y = max(foot_constraint.global_position.y, ground_y)

	anchor_constraint.global_position.y = ground_y - anchor_offset
	anchor_constraint.apply_transform()

Implementation

  1. Add a SpineSprite to a scene in Godot as is normal practice.
  2. Add 3 SpineBoneNodes as children of the SpineSprite: one for the Anchor bone, one for the Left Foot and one for the Right Foot.
  3. Attach a bone_constraint script to the Anchor SpineBoneNode.
  4. To each of the Foot nodes, attach a foot_constraint script.
  5. On each of the SpineBoneNodes under the SpineBoneNode properties, ensure Bone Mode is set to Follow and assign the appripriate Bone Name. By a fun quirk, this should also set the Bone Name of the BoneConstraint. If it doesn't, ensure the Bone Names match for each.
  6. Set the FootConstraint properties as is appropriate.
  7. Now add a new Node2D as a child of the SpineSprite.
  8. Attach a ground_constraints script to the new Node2D.
  9. Assign the GroundConstraints properties by drag and drop for the Node references, and set the Anchor Offset to an appropriate value.

After this, you should be able to run the scene and see the character's feet adjust to the slope of the terrain. Something that's missing from my solution that will be needed is a property for the ground mask to use with the ray casts.

API Improvements

The bone_constraint.gd script wouldn't be need if SpineBoneNode implemented its own version of the apply_transform method. I'm not sure if it should be an exposed, parameterless overload of SpineBoneNode::update_transform or if it should be called something like update_bone_transform. Alternatively, SpineBoneNode could provide a bone property, or expose the find_bone method. I personally think both an apply/update transform method and a way to access the bone should be provided, as accessing the bone of a SpineBoneNode seems like something that would be useful.

The other note/question I have, is in foot_constraint.gd I have a line commented out noting that setting global_position causes the bone to get stuck in position (setting just y gets around the problem). I was wondering why this is the case. I would expect the bone's position to be reflect the animation data on the next world_transforms_changed signal.

I hope others find this useful, and thanks for you continuous prompt responses!

I've just added find_bone() and find_sprite() to SpineBoneNode, so you can easily fetch the SpineBone and SpineSprite the SpineBoneNode is attached to. I'd rather not supply an additional method that sets the bone's global transform to the SpineBoneNode global transform, especially since it can be confused with other update/apply methods. If you are working on this level of the API, I think it's reasonable to expect that you fetch the bone and modify it as you like (e.g. you may not want to apply the full transform, but only position, or rotation, or scale).
EsotericSoftware/spine-runtimes2447

As for the bone getting stuck, I'd have to debug it. As you said, upon the next world_transforms_changed signal, the bone should have a transform that matches what the animation system has generated by applying the current frame of the animation. Could you maybe provide me with a scene that helps me debug the issue? I'd rather not spend time trying to recreate what you have based on your description, as I might get a minor detail wrong and end up debugging a different thing 🙂

Just a follow up on this. I have since discovered that implementing horizontal flipping creates some spectacularly whack transform results. If you add the following code to the player.gd script included in the emailed project, you'll see what I'm taking about:

func _process(_delta_time: float):
	var input_x: float = Input.get_axis("ui_left", "ui_right")

	if (input_x < 0):
		get_skeleton().set_scale_x(-1)
	elif (input_x > 0):
		get_skeleton().set_scale_x(1)

Run the scene and use the left and right arrow keys to change direction several times to reproduce.