Code/BetterIk/TwoBoneIK.cs

Component that performs runtime two-bone inverse kinematics for an end bone (typical arm/leg). It finds a SkinnedModelRenderer, resolves a 3-bone chain automatically (with optional overrides), computes IK each frame in OnPreRender, and writes bone transforms back to the renderer. It also draws gizmos for reach and pole direction.

Native Interop
#nullable enable

using System.Diagnostics.CodeAnalysis;
using Sandbox;
using BetterIk.Maths;
using BetterIk.Skeleton;

namespace BetterIk;

/// <summary>
/// Runtime two-bone IK (arm/leg) with pole vector control. Drop on the model root (or any
/// descendant of it - the component finds the SkinnedModelRenderer via GetComponentInParent),
/// set EndBone to the hand/foot bone name, optionally drag in a Target and PoleTarget. Walks
/// up the skeleton automatically to find the mid and root bones; no per-model setup needed.
/// </summary>
public sealed class TwoBoneIK : Component, IHasSkinnedRenderer
{
	// --- Setup (the happy path: add component, set EndBone, drag Target, done) ---
	[Property] public SkinnedModelRenderer? Renderer { get; set; }
	[Property, BoneName] public string EndBone { get; set; } = "";
	[Property] public GameObject? Target { get; set; }
	[Property] public GameObject? PoleTarget { get; set; }

	// --- Weights (all lerp-safe every frame) ---
	[Property, Range( 0f, 1f )] public float Weight { get; set; } = 1f;
	[Property, Range( 0f, 1f )] public float PositionWeight { get; set; } = 1f;
	[Property, Range( 0f, 1f )] public float RotationWeight { get; set; } = 1f;

	// --- Advanced: manual bone overrides, pole fine-tuning, soft/stretch (off by default) ---
	[Property, BoneName, Group( "Advanced" )] public string RootBoneOverride { get; set; } = "";
	[Property, BoneName, Group( "Advanced" )] public string MidBoneOverride { get; set; } = "";
	[Property, Group( "Advanced" ), Range( -180f, 180f )] public float PoleAngleOffsetDegrees { get; set; } = 0f;
	[Property, Group( "Advanced" ), Range( 0f, 0.49f )] public float SoftFraction { get; set; } = 0f;
	[Property, Group( "Advanced" ), Range( 0f, 1f )] public float MaxStretch { get; set; } = 0f;

	// --- Diagnostics ---
	public bool HasValidChain { get; private set; }
	public bool LastSolveApplied { get; private set; }

	private (SkinnedModelRenderer Renderer, string EndBone, string RootOverride, string MidOverride) _cachedSignature;
	// Only valid once EnsureResolved() has returned true at least once, same as a RequireComponent
	// field is only valid once OnStart has run.
	private BoneCollection.Bone _rootBone = null!;
	private BoneCollection.Bone _midBone = null!;
	private BoneCollection.Bone _endBone = null!;
	private BindPoseData _bindPose;
	private Vector3 _lastPoleDirection = Vector3.Up;

	protected override void OnStart()
	{
		Renderer ??= GameObject.GetComponent<SkinnedModelRenderer>()
			?? GameObject.GetComponentInParent<SkinnedModelRenderer>( includeSelf: true );
	}

	protected override void OnPreRender()
	{
		Solve();
	}

	private void Solve()
	{
		if ( !EnsureResolved() )
		{
			HasValidChain = false;
			LastSolveApplied = false;
			return;
		}

		HasValidChain = true;

		if ( Target is null || !Target.IsValid )
		{
			LastSolveApplied = false;
			return;
		}

		// Weight <= 0 skips the read-solve-write cycle entirely rather than reading the animated
		// pose and writing an "unchanged" result back. The world<->model-local round trip through
		// SetBoneTransform is NOT perfectly lossless every frame - on a rig with no real animation
		// driving it, this compounded into Infinity within a few seconds even though the math is
		// a no-op at Weight=0. Not writing at all when there is nothing to blend removes the
		// feedback path entirely.
		if ( Weight <= 0f )
		{
			LastSolveApplied = false;
			return;
		}

		Renderer.TryGetBoneTransformAnimation( in _rootBone, out var rootTx );
		Renderer.TryGetBoneTransformAnimation( in _midBone, out var midTx );
		Renderer.TryGetBoneTransformAnimation( in _endBone, out var endTx );

		Vector3 poleHint = PoleTarget is not null && PoleTarget.IsValid
			? PoleTarget.WorldPosition - rootTx.Position
			: rootTx.Rotation * _bindPose.DefaultPoleDirection.ToSandbox();

		_lastPoleDirection = poleHint.Normal;

		var input = new TwoBoneIkInput
		{
			RootPosition = rootTx.Position.ToNumerics(),
			MidPosition = midTx.Position.ToNumerics(),
			EndPosition = endTx.Position.ToNumerics(),
			RootRotation = rootTx.Rotation.ToNumerics(),
			MidRotation = midTx.Rotation.ToNumerics(),
			EndRotation = endTx.Rotation.ToNumerics(),
			TargetPosition = Target.WorldPosition.ToNumerics(),
			TargetRotation = Target.WorldRotation.ToNumerics(),
			HasPole = true, // poleHint always carries either the real pole or the bind-pose default
			PoleHint = poleHint.ToNumerics(),
			PoleAngleOffsetRadians = PoleAngleOffsetDegrees * (MathF.PI / 180f),
			FallbackBendNormal = _bindPose.BendNormal,
			PositionWeight = PositionWeight,
			RotationWeight = RotationWeight,
			MasterWeight = Weight,
			SoftFraction = SoftFraction,
			MaxStretch = MaxStretch,
		};

		var result = TwoBoneIkSolver.Solve( input );

		// SetBoneTransform expects model-local space, unlike TryGetBoneTransformAnimation/
		// TryGetBoneTransform which are documented as worldspace - see MathBridge.ToModelLocal.
		Renderer.SetBoneTransform( in _rootBone, Renderer.ToModelLocal( new global::Transform( rootTx.Position, result.RootRotation.ToSandbox() ).WithScale( rootTx.Scale ) ) );
		Renderer.SetBoneTransform( in _midBone, Renderer.ToModelLocal( new global::Transform( result.MidPosition.ToSandbox(), result.MidRotation.ToSandbox() ).WithScale( midTx.Scale ) ) );
		Renderer.SetBoneTransform( in _endBone, Renderer.ToModelLocal( new global::Transform( result.EndPosition.ToSandbox(), result.EndRotation.ToSandbox() ).WithScale( endTx.Scale ) ) );

		// PostAnimationUpdate is [Obsolete] with no replacement documented in the shipped XML
		// docs; kept defensively until checklist item 3 is verified against a live editor.
#pragma warning disable CS0612
		Renderer.PostAnimationUpdate();
#pragma warning restore CS0612

		LastSolveApplied = result.Solved;
	}

	[MemberNotNullWhen( true, nameof( Renderer ) )]
	private bool EnsureResolved()
	{
		if ( Renderer is null || Renderer.Model is null || string.IsNullOrEmpty( EndBone ) )
			return false;

		var signature = (Renderer, EndBone, RootBoneOverride, MidBoneOverride);
		if ( signature.Equals( _cachedSignature ) && _rootBone is not null )
			return true;

		var bones = Renderer.Model.Bones;
		if ( !bones.HasBone( EndBone ) )
			return false;

		IBoneNode endNode = new SandboxBoneNode( bones.GetBone( EndBone ) );

		IBoneNode? rootOverrideNode = null;
		if ( !string.IsNullOrEmpty( RootBoneOverride ) )
		{
			if ( !bones.HasBone( RootBoneOverride ) )
				return false;
			rootOverrideNode = new SandboxBoneNode( bones.GetBone( RootBoneOverride ) );
		}

		IBoneNode? midOverrideNode = null;
		if ( !string.IsNullOrEmpty( MidBoneOverride ) )
		{
			if ( !bones.HasBone( MidBoneOverride ) )
				return false;
			midOverrideNode = new SandboxBoneNode( bones.GetBone( MidBoneOverride ) );
		}

		var chain = BoneChainResolver.Resolve( endNode, rootOverrideNode, midOverrideNode );
		if ( !chain.Success )
			return false;

		_rootBone = ((SandboxBoneNode)chain.Root!).Bone;
		_midBone = ((SandboxBoneNode)chain.Mid!).Bone;
		_endBone = ((SandboxBoneNode)chain.End!).Bone;
		_cachedSignature = signature;

		// Bind-pose world positions, composed from an arbitrary origin at the root - only the
		// relative offsets matter to AnalyzeBindPose (translation-invariant), so we don't need
		// to walk all the way up to the model's true origin.
		var rootBindWorld = global::Transform.Zero;
		var midBindWorld = global::Transform.Concat( rootBindWorld, _midBone.LocalTransform );
		var endBindWorld = global::Transform.Concat( midBindWorld, _endBone.LocalTransform );

		_bindPose = TwoBoneIkSolver.AnalyzeBindPose(
			rootBindWorld.Position.ToNumerics(),
			midBindWorld.Position.ToNumerics(),
			endBindWorld.Position.ToNumerics() );

		return true;
	}

	protected override void DrawGizmos()
	{
		if ( !EnsureResolved() )
		{
			Gizmo.Draw.Color = Color.Red;
			Gizmo.Draw.WorldText( "TwoBoneIK: invalid bone chain", new global::Transform( WorldPosition ) );
			return;
		}

		if ( Target is null || !Target.IsValid )
			return;

		Renderer.TryGetBoneTransformAnimation( in _rootBone, out var rootTx );
		Renderer.TryGetBoneTransformAnimation( in _midBone, out var midTx );
		Renderer.TryGetBoneTransformAnimation( in _endBone, out var endTx );

		float l1 = (midTx.Position - rootTx.Position).Length;
		float l2 = (endTx.Position - midTx.Position).Length;
		float lmax = l1 + l2;
		float d = (Target.WorldPosition - rootTx.Position).Length;
		float minReach = MathF.Abs( l1 - l2 );
		float softStart = (1f - SoftFraction) * lmax;

		Color reachColor;
		if ( d < minReach || d > lmax * (1f + MaxStretch) )
			reachColor = Color.Red;
		else if ( SoftFraction > 0f && d > softStart )
			reachColor = Color.Yellow;
		else
			reachColor = Color.Green;

		// Solved chain (post-IK), reusing the same read this frame's Solve() already wrote.
		Renderer.TryGetBoneTransform( in _rootBone, out var solvedRoot );
		Renderer.TryGetBoneTransform( in _midBone, out var solvedMid );
		Renderer.TryGetBoneTransform( in _endBone, out var solvedEnd );

		Gizmo.Draw.Color = reachColor;
		Gizmo.Draw.Line( solvedRoot.Position, solvedMid.Position );
		Gizmo.Draw.Line( solvedMid.Position, solvedEnd.Position );

		Gizmo.Draw.Color = Color.White;
		Gizmo.Draw.LineSphere( new Sphere( Target.WorldPosition, MathF.Max( lmax * 0.05f, 1f ) ) );

		Gizmo.Draw.Color = PoleTarget is not null && PoleTarget.IsValid ? Color.White : Color.Cyan;
		Gizmo.Draw.Arrow( solvedMid.Position, solvedMid.Position + _lastPoleDirection * (lmax * 0.3f), lmax * 0.05f, lmax * 0.03f );

		Gizmo.Draw.Color = Color.Cyan.WithAlpha( 0.15f );
		Gizmo.Draw.SolidTriangle( new Triangle { A = solvedRoot.Position, B = solvedMid.Position, C = solvedEnd.Position } );
	}
}