BoBiCo Soft-Physics/SoftBoneCollisionSolver.cs

Soft bone collision solver and collider shape definitions. Defines SoftBoneColliderShape value type for sphere, capsule, box, flat plane, and provides collision resolution (Resolve) that pushes a bone endpoint out of colliders while respecting a constrained root distance and min/max lengths.

Native Interop
using System;

public enum SoftBoneColliderShapeType
{
	Sphere,
	Capsule,
	Box,
	Flat,
}

public readonly struct SoftBoneColliderShape
{
	public readonly SoftBoneColliderShapeType Type;
	public readonly Vector3 Start;
	public readonly Vector3 End;
	public readonly float Radius;
	public readonly Vector3 Center;
	public readonly Rotation Rotation;
	public readonly Vector3 Size;
	public readonly Vector3 Normal;

	private SoftBoneColliderShape( SoftBoneColliderShapeType type, Vector3 start, Vector3 end, float radius, Vector3 center, Rotation rotation, Vector3 size, Vector3 normal )
	{
		Type = type;
		Start = start;
		End = end;
		Radius = radius;
		Center = center;
		Rotation = rotation;
		Size = size;
		Normal = normal;
	}

	public bool IsSphere => Type == SoftBoneColliderShapeType.Sphere;
	public static SoftBoneColliderShape Sphere( Vector3 center, float radius ) => new( SoftBoneColliderShapeType.Sphere, center, center, radius, center, Rotation.Identity, Vector3.Zero, Vector3.Zero );
	public static SoftBoneColliderShape Capsule( Vector3 start, Vector3 end, float radius ) => new( SoftBoneColliderShapeType.Capsule, start, end, radius, (start + end) * 0.5f, Rotation.Identity, Vector3.Zero, Vector3.Zero );
	public static SoftBoneColliderShape Box( Vector3 center, Rotation rotation, Vector3 size ) => new( SoftBoneColliderShapeType.Box, Vector3.Zero, Vector3.Zero, 0f, center, rotation, size, Vector3.Zero );
	public static SoftBoneColliderShape Flat( Vector3 center, Vector3 normal ) => new( SoftBoneColliderShapeType.Flat, Vector3.Zero, Vector3.Zero, 0f, center, Rotation.Identity, Vector3.Zero, normal.Normal );
}

public static class SoftBoneCollisionSolver
{
	private const float Epsilon = 0.000008f;

	public static bool Resolve( ref Vector3 endpoint, Vector3 constraintBegin, Vector3 contactBegin, float boneRadius, float minLength, float maxLength, in SoftBoneColliderShape collider )
	{
		var link = endpoint - constraintBegin;
		var linkLength = link.Length;
		if ( linkLength <= Epsilon )
			return false;

		var contact = endpoint;
		var contactT = 1f;
		Vector3 closest;
		Vector3 normal;
		float penetration;

		switch ( collider.Type )
		{
			case SoftBoneColliderShapeType.Sphere:
			case SoftBoneColliderShapeType.Capsule:
				ClosestPointsOnSegments( contactBegin, endpoint, collider.Start, collider.End, out contact, out closest, out _ );
				contactT = Math.Clamp( Vector3.Dot( contact - constraintBegin, link ) / (linkLength * linkLength), 0f, 1f );
				if ( !TrySphereContact( contact, closest, boneRadius + collider.Radius, link, collider.End - collider.Start, out normal, out penetration ) )
					return false;
				break;

			case SoftBoneColliderShapeType.Box:
				contact = endpoint;
				closest = ClosestPointOnBox( contact, collider, out var isInside );
				var offset = contact - closest;
				var distance = offset.Length;
				if ( isInside )
				{
					normal = InsideBoxNormal( contact, collider );
					penetration = boneRadius + InsideBoxDepth( contact, collider );
				}
				else if ( distance < boneRadius )
				{
					normal = distance > Epsilon ? offset / distance : FallbackNormal( link, Vector3.Up );
					penetration = boneRadius - distance;
				}
				else return false;
				break;

			case SoftBoneColliderShapeType.Flat:
				contact = endpoint;
				var planeDistance = Vector3.Dot( contact - collider.Center, collider.Normal );
				if ( planeDistance >= boneRadius )
					return false;
				normal = collider.Normal;
				penetration = boneRadius - planeDistance;
				break;

			default:
				return false;
		}

		// The root remains fixed. Scaling the projection by contactT moves only the free tail.
		var influence = Math.Max( contactT, 0.12f );
		endpoint += normal * (penetration / influence);
		var corrected = endpoint - constraintBegin;
		var correctedLength = Math.Clamp( corrected.Length, minLength, maxLength );
		endpoint = constraintBegin + SafeNormal( corrected, link ) * correctedLength;
		return true;
	}

	private static bool TrySphereContact( Vector3 contact, Vector3 closest, float radius, Vector3 link, Vector3 bodyAxis, out Vector3 normal, out float penetration )
	{
		var offset = contact - closest;
		var distance = offset.Length;
		if ( distance >= radius )
		{
			normal = Vector3.Zero;
			penetration = 0f;
			return false;
		}

		normal = distance > Epsilon ? offset / distance : FallbackNormal( link, bodyAxis );
		penetration = radius - distance;
		return true;
	}

	private static Vector3 ClosestPointOnBox( Vector3 point, in SoftBoneColliderShape box, out bool isInside )
	{
		var local = box.Rotation.Inverse * (point - box.Center);
		var half = box.Size * 0.5f;
		isInside = Math.Abs( local.x ) <= half.x && Math.Abs( local.y ) <= half.y && Math.Abs( local.z ) <= half.z;
		local = new Vector3( Math.Clamp( local.x, -half.x, half.x ), Math.Clamp( local.y, -half.y, half.y ), Math.Clamp( local.z, -half.z, half.z ) );
		return box.Center + box.Rotation * local;
	}

	private static Vector3 InsideBoxNormal( Vector3 point, in SoftBoneColliderShape box )
	{
		var local = box.Rotation.Inverse * (point - box.Center);
		var half = box.Size * 0.5f;
		var x = half.x - Math.Abs( local.x );
		var y = half.y - Math.Abs( local.y );
		var z = half.z - Math.Abs( local.z );
		var normal = x <= y && x <= z ? new Vector3( local.x < 0f ? -1f : 1f, 0f, 0f ) : y <= z ? new Vector3( 0f, local.y < 0f ? -1f : 1f, 0f ) : new Vector3( 0f, 0f, local.z < 0f ? -1f : 1f );
		return box.Rotation * normal;
	}

	private static float InsideBoxDepth( Vector3 point, in SoftBoneColliderShape box )
	{
		var local = box.Rotation.Inverse * (point - box.Center);
		var half = box.Size * 0.5f;
		return Math.Min( half.x - Math.Abs( local.x ), Math.Min( half.y - Math.Abs( local.y ), half.z - Math.Abs( local.z ) ) );
	}

	private static Vector3 FallbackNormal( Vector3 boneAxis, Vector3 bodyAxis )
	{
		var normal = Vector3.Cross( boneAxis, bodyAxis );
		if ( normal.Length > Epsilon ) return normal.Normal;
		normal = Vector3.Cross( boneAxis, Vector3.Up );
		return normal.Length > Epsilon ? normal.Normal : Vector3.Right;
	}

	private static Vector3 SafeNormal( Vector3 value, Vector3 fallback ) => value.Length > Epsilon ? value.Normal : fallback.Normal;

	private static void ClosestPointsOnSegments( Vector3 a0, Vector3 a1, Vector3 b0, Vector3 b1, out Vector3 pointA, out Vector3 pointB, out float aT )
	{
		var a = a1 - a0;
		var b = b1 - b0;
		var r = a0 - b0;
		var aa = Vector3.Dot( a, a ); var bb = Vector3.Dot( b, b ); var ab = Vector3.Dot( a, b );
		var ar = Vector3.Dot( a, r ); var br = Vector3.Dot( b, r );
		var denominator = aa * bb - ab * ab;
		var s = 0f; var t = 0f;
		if ( aa <= Epsilon ) t = bb <= Epsilon ? 0f : Math.Clamp( br / bb, 0f, 1f );
		else if ( bb <= Epsilon ) s = Math.Clamp( -ar / aa, 0f, 1f );
		else { s = denominator > Epsilon ? Math.Clamp( (ab * br - bb * ar) / denominator, 0f, 1f ) : 0f; t = Math.Clamp( (ab * s + br) / bb, 0f, 1f ); s = Math.Clamp( (ab * t - ar) / aa, 0f, 1f ); }
		aT = s; pointA = a0 + a * s; pointB = b0 + b * t;
	}
}