Picking up interactive 2D ragdoll by limbs causes erratic joint behaviour

Godot Version

4.2

Question

I have a physics ragdoll which the player can pick up and throw around with their mouse. I want the captured limb to follow the mouse, so I enable custom integrator for that specific limb and set the velocity based on the mouse’s position each frame. It seems to work okay at low velocities, but when you try to pull a limb at a higher velocity the rest of the ragdoll behaves erratically.

A higher bias setting leads to more spasms, while a higher softness setting fixes the spasms but creates gaps between the joints (which I don’t want).

Any ideas for what I’m doing wrong? Or a different approach?

Hey, my comment would be on the different approach crowd but if someone else has a suggestion on how to fix your current one then that’s awesome.

Anyway, I’m working on some ropes and they have the same issue at high speeds (like shooting an arrow with a rope attached to it) so I’m using verlet integration for it and if you constrain it properly with very stiff sticks, it works much better. Verlet is great for ragdolls as well, you just have to do the particle/stick thing and simulate it yourself. This is a bit of a rabbit hole but you can use the physics server to do collisions as well. I can share some code if you want to see an example, although my collision code is not great for the ropes haha.

Here’s a video of the rope, although in my game they don’t go that fast, I just modified it to follow the mouse to show you. You can see some stretching but I could fix it with better constraints or more iterations in the constraint part.

Here’s a good tutorial but it’s in unity:

There’s a lot of verlet integration tutorials out there. It’s a bit of a rabbit hole so take my comment with a big grain of salt hahaha

I promised some code for my rope, it’s a bit lengthy and in my game it does a lot of things but you should be able to copy the whole thing into your project and try it yourself.

Finally, a ragdoll is like a rope but there’s sticks connecting different joints so it’s more solid so you’d have to modify this heavily to make it work. Hope it helps!

@tool
extends Area2D
class_name VerletRope

@export var color := Color.WHITE
@export_range(-TAU, TAU) var starting_angle := 0.0
@export var texture : Texture
@export var texture_size := Vector2(1,1)
@export var texture_offset := Vector2()
@export_range(-TAU, TAU) var texture_rotation := 0.0
@export_range(0, 1) var friction := 0.95
@export var dampening := 0.95
@export var thickness := 5.0
@export var thickness_curve : Curve
@export var particles := 5
@export var segment_length := 20.0
@export var pin_start := true
@export var pin_end := false
@export_flags_2d_physics var collide_with := 0
var is_copy := false

var points : PackedVector2Array
var prevPoints : PackedVector2Array
var segments : Array[VerletRopeSegment]
var forces : PackedVector2Array
var shape : RID

var draw_points : PackedVector2Array

func _ready():
	if !Engine.is_editor_hint():
		shape = PhysicsServer2D.circle_shape_create()
		PhysicsServer2D.shape_set_data(shape, thickness)
	if !is_copy:
		make_rope()


func make_rope():
	for seg in segments:
		seg.queue_free()
	segments.clear()
	forces.resize(particles)
	points.resize(particles)
	prevPoints.resize(particles)
	for i in particles: # -1 to get correct segment ocunt
		if i == particles - 1:
			var start := Vector2.from_angle(starting_angle) * segment_length * i
			points[i] = start
		else:
			var start := Vector2.from_angle(starting_angle) * segment_length * i
			var shape := VerletRopeSegment.new()
			var segment_shape := SegmentShape2D.new()
			#shape.z_index = -1
			var t := remap(i, 0, particles - 2, 0, 1)
			var t2 := remap(i + 1, 0, particles - 2, 0, 1)
			var thickness_t := thickness_curve.sample_baked(t) if thickness_curve else 1.0
			var thickness_t2 := thickness_curve.sample_baked(t2) if thickness_curve else 1.0
			shape.segment_length = segment_length
			shape.color = color
			shape.left_thickness = thickness * thickness_t
			shape.right_thickness = thickness * thickness_t2
			shape.texture = texture
			shape.texture_size = texture_size
			shape.texture_offset = texture_offset
			shape.texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED
			add_child(shape)
			shape.position = start
			segment_shape.a = Vector2(0,0)
			segment_shape.b = Vector2(segment_length, 0)
			shape.shape = segment_shape
			points[i] = start
			segments.append(shape)
	prevPoints = points.duplicate()

func _draw():
	for p in points:
		draw_circle(p, 4, Color.RED)
	for p in draw_points:
		draw_circle(p, 20, Color.GREEN)
	draw_points.clear()
	
func _physics_process(delta : float):
	#return
	if points.size() != particles:
		make_rope()
	apply_force(Vector2.DOWN * 98)
	calculate_points(delta)
	for _j in 5:
		apply_constrains()
	for i in segments.size():
		var shape := segments[i]
		shape.position = points[i]
	queue_redraw()
		
func calculate_points(delta : float):
	var space_state := get_world_2d().direct_space_state
	
	for i in particles:
		if (i != 0 && i != particles - 1) || (i == 0 && !pin_start) || (i == particles - 1 && !pin_end):
			var collision := collide_point(space_state,shape, to_global(points[i]))
			var is_colliding := !collision.is_empty()
			var velocity := (points[i] - prevPoints[i]) * dampening
			velocity += forces[i] * delta
			if is_colliding:
				#velocity -= Vector2.DOWN * minf(Vector2.DOWN.dot(velocity), 0) # remove gravity force if colliding
				velocity.y = minf(velocity.y, 0)
				
			if is_colliding && collision.normal.dot(velocity) < 0: # only when going into the collision
				points[i] = to_local(collision.point + collision.normal)
				prevPoints[i] = to_local(collision.point + collision.normal)
			else:
				prevPoints[i] = points[i]
				points[i] += velocity

	forces.fill(Vector2.ZERO)

func apply_constrains():
	for i in particles:
		if i == particles - 1:
			return
		var distance := points[i].distance_to(points[i+1])
		var difference := segment_length - distance
		var percent := difference / distance
		var offset := points[i+1] - points[i]
		segments[i].rotation = offset.angle()
		if i == 0:
			if pin_start:
				points[i+1] += offset * percent
			else:
				points[i]  -= offset * (percent / 2)
				points[i+1] += offset * (percent / 2)
		else:
			if pin_end && i + 1 == particles - 1:
				points[i] -= offset * percent
			else:
				points[i]  -= offset * (percent / 2)
				points[i+1] += offset * (percent / 2)
				
func apply_force_to_segment(index : int, force : Vector2, wave_factor := 0.5, limit := -1):
	if Utils.index_in_array(forces, index):
		forces[index] += force
		var left_propagation := range(index - 1, -1, -1)
		var right_propagation := range(index + 1, points.size())
		var left_factor := force * wave_factor
		var right_factor := force * wave_factor
		var left_count := 0
		var right_count := 0
		for i in left_propagation:
			if left_factor.is_zero_approx() || (limit > -1 && left_count >= limit):
				break
			forces[i] += left_factor
			left_factor *= wave_factor
			left_count += 1
		for i in right_propagation:
			if right_factor.is_zero_approx() || (limit > -1 && right_count >= limit):
				break
			forces[i] += right_factor
			right_factor *= wave_factor
			right_count += 1
		
func apply_force(force : Vector2):
	for i in forces.size():
		forces[i] += force

func get_closest_point_index(anchor : Vector2, skip := -1) -> int:
	var min := INF
	var index := -1
	for i in points.size():
		if i == skip:
			continue
		var point := points[i]
		var global := to_global(point)
		var dist := global.distance_to(anchor)
		if dist < min:
			min = dist
			index = i
	return index
	
func get_closest_point(anchor : Vector2) -> Vector2:
	var min := INF
	var index := -1
	for i in points.size():
		var point := points[i]
		var global := to_global(point)
		var dist := global.distance_squared_to(anchor)
		if dist < min:
			min = dist
			index = i
	return to_global(points[index])

func get_point(index : int) -> Vector2:
	var clamped := clampi(index, 0, points.size())
	if Utils.index_in_array(points, clamped):
		if clamped > 0:
			var prev := points[index - 1]
			return to_global(points[clamped].lerp(prev, 0.5))
		return to_global(points[clamped])
	return global_position

func split_rope(index : int):
	if Utils.index_in_array(points,index) && index > 0 && index < points.size() - 1:
		var rope := new_rope_from_index(index)
		var dangler := segments[index - 1]
		points = points.slice(0, index)
		particles = points.size()
		prevPoints = prevPoints.slice(0, index)
		segments = segments.slice(0, index - 1)
		forces = forces.slice(0, index)
		dangler.call_deferred("queue_free")
		


func new_rope_from_index(index : int) -> VerletRope:
	var rope := VerletRope.new()
	rope.is_copy = true
	rope.texture = texture
	rope.texture_size = texture_size
	rope.texture_offset = texture_offset
	rope.texture_rotation = texture_rotation
	rope.dampening = dampening
	rope.thickness = thickness
	rope.collide_with = collide_with
	rope.segment_length = segment_length
	get_parent().add_child(rope)
	rope.points = points.slice(index)
	rope.global_position = global_position
	rope.particles = rope.points.size()
	rope.prevPoints = prevPoints.slice(index)
	rope.segments = segments.slice(index)
	for seg in rope.segments:
		seg.reparent(rope)
	rope.forces = forces.slice(index)
	rope.pin_end = false
	rope.pin_start = false
	return rope

func can_hang_from() -> bool:
	return pin_end || pin_start

func collide_point(space_state : PhysicsDirectSpaceState2D,shape : RID, point : Vector2) -> Dictionary:
	if Engine.is_editor_hint():
		return {}
	var params := PhysicsShapeQueryParameters2D.new()
	params.shape_rid = shape
	params.collision_mask = collide_with
	params.transform = Transform2D(0, point)
	return space_state.get_rest_info(params)
	
func get_velocity(index : int) -> Vector2:
	if Utils.index_in_array(points, index):
		return points[index] - prevPoints[index]
	return Vector2.ZERO
	
func _exit_tree():
	if shape.is_valid():
		PhysicsServer2D.free_rid(shape)

1 Like

Forgot another class referenced in my previous comment:
The Polygons class is my own class that let’s you draw polygons easily so there’s a bit of a dependency trail here. I’ll paste the code so it works for you but that’s out of topic a bit.

VerletSegment.gd

@tool
extends CollisionShape2D
class_name VerletRopeSegment

var segment_length := 50.0
var color := Color.WHITE
var left_thickness := 5.0
var right_thickness := 5.0
var texture : Texture
var texture_size := Vector2(1,1)
var texture_offset := Vector2()
var texture_rotation := 0.0

var polygon := PackedVector2Array()
var uvs := PackedVector2Array()

func _ready():
	polygon.clear()
	var line := Polygons.line_to_rect(Vector2(0,0), Vector2(segment_length, 0),left_thickness,right_thickness)
	for i in line.size():
		var start := line[i]
		var end := line[wrapi(i + 1, 0, line.size())]
		if i % 2 == 0:
			if i > 0:
				polygon.append(start)
			if i < line.size() - 1:
				polygon.append(end)
		else:
			polygon.append_array(Polygons.line_to_arc(start,end,10))
	uvs = Polygons.get_polygon_uvs(polygon, Polygons.get_bounding_box(polygon), true, texture_size,texture_offset, texture_rotation)

func _draw():
	if Polygons.can_draw_polygon(polygon):
		draw_colored_polygon(polygon, color, uvs, texture)

Polygons.gd

extends Node
class_name Polygons

static func line_to_rect(start : Vector2, end : Vector2, left_height : float, right_height : float) -> PackedVector2Array:
	var dir := start.direction_to(end)
	var rotated := Vector2(dir.y, dir.x * -1).normalized()
	# get points clockwise
	var top_left := start + rotated * left_height
	var top_right := end + rotated * right_height
	var bottom_right := end - rotated * right_height
	var bottom_left := start - rotated * left_height
	return PackedVector2Array([top_left, top_right, bottom_right, bottom_left ])	

static func line_to_arc(start : Vector2, end : Vector2, n_points : int = 32, dir := 1, skip_first := false) -> PackedVector2Array:
	var points := PackedVector2Array()
	var radius := start.distance_to(end) * 0.5
	var middle := (start + end) * 0.5
	var increment := PI / n_points
	for i in n_points:
		var angle := increment * i
		var point : Vector2 = middle + middle.direction_to(start).rotated(angle * dir) * radius
		points.append(point)
	if skip_first:
		points.remove_at(0)
	return points

static func get_polygon_uvs(polygon : PackedVector2Array, box : Rect2, square : bool = false, scale : Vector2 = Vector2(1,1), offset : Vector2 = Vector2(), rotation := 0.0) -> PackedVector2Array:
	if square:
		var _max = box.size.x if box.size.x > box.size.y else box.size.y
		box = box.expand(Vector2(_max - box.size.x, _max - box.size.y))
	var uvs := PackedVector2Array()
	for point in polygon:
		uvs.append(Vector2(point.x / box.size.x * scale.x, point.y / box.size.y * scale.y) + offset)
	if rotation != 0:
		return uvs * Transform2D(rotation, Vector2(0,0))
	return uvs


static func get_bounding_box(points : PackedVector2Array) -> Rect2:
	var min_x: float = INF
	var min_y: float = INF
	var max_x: float = -INF
	var max_y: float = -INF

	for point in points:
		min_x = min(min_x, point.x)
		min_y = min(min_y, point.y)
		max_x = max(max_x, point.x)
		max_y = max(max_y, point.y)
	# Calculate width and height for the Rect2
	var width: float = max_x - min_x
	var height: float = max_y - min_y    
	return Rect2(min_x, min_y, width, height)

static func can_draw_polygon(poly : PackedVector2Array):
	return !Geometry2D.triangulate_polygon(poly).is_empty()
1 Like

Thank you for such a detailed response! I started watching videos about verlet integration shortly after posting this thread, but hadn’t had time to implement it myself. I’ll try applying some of your code snippets to my own project (double the thanks for sharing them by the way).

In the meantime, I’ve found the Godot Box2D plugin provides better results and predictability for tethered rigid body objects. It’s not perfect, but it might be the best I can hope for if I can’t get the collision detection to work with verlet.