Component that implements a soft-bone chain for character rigs. It builds a chain of joint states from a SkinnedModelRenderer, simulates soft-bone physics each frame (spring, pull, gravity, stretch/squish, limits), resolves collisions against collider groups and publishes chain collider shapes for other chains, and draws gizmos in the editor.
//=========================================================================================================================
// BoBiCo Soft-bone Chain Component
//=========================================================================================================================
using System;
using System.Collections.Generic;
public enum SoftBoneLinkColliderShape
{
/// <summary>
/// A simple spherical collider.
/// Best for rounded body parts and lightweight collision detection.
/// </summary>
Sphere,
/// <summary>
/// A capsule-shaped collider.
/// Recommended for most bones and elongated body parts.
/// </summary>
Capsule,
/// <summary>
/// A box-shaped collider.
/// Useful for broad collision areas and block-like shapes.
/// </summary>
Box,
/// <summary>
/// A one-sided flat collider.
/// Useful for flat surface areas.
/// </summary>
Flat,
}
public enum SoftBoneCollisionMode
{
/// <summary>
/// Disables all collision detection for this chain.
/// </summary>
None,
/// <summary>
/// Collides only with body colliders.
/// This is the recommended mode for most setups.
/// </summary>
BaseCollision,
/// <summary>
/// Collides with body colliders and collider chains from the same model.
/// </summary>
SelfCollision,
/// <summary>
/// Collides with body colliders and collider chains from all SoftBone-enabled models.
/// </summary>
FullCollision
}
[Title( "SoftBone Chain" )]
[Category( "BoBiCo" )]
[Icon( "linear_scale" )]
[HelpUrl( "https://bobico-lab.vercel.app/softbone-docs/bobico-softbones/core-components/softbone-chain" )]
public sealed class BoBiCoSoftBoneChain : Component, Component.ExecuteInEditor
{
// prevents unstable math when values are extremely close to zero.
private const float Epsilon = 0.000008f;
private GameObject _targetBone;
private SoftBoneControlMode _controlMode;
private bool _curveDefaultsInitialized;
//=========================================================================================================================
// Chain Properties Section
//=========================================================================================================================
/// <summary>
/// Defines the root bone of the chain that'll be simulated.
/// </summary>
[Property, Order( 1 ), Title( "Target Bone" ), Group( "Chain Properties" )]
public GameObject TargetBone
{
get => _targetBone;
set
{
if ( _targetBone == value )
return;
_targetBone = value;
UpdateOwnerName();
MarkDirty();
}
}
/// <summary>
/// Rotates the chain in local space.
/// </summary>
[Property, Order( 2 ), Title( "Rotation Offset" ), Group( "Chain Properties" )]
public Angles RotationOffset { get; set; }
/// <summary>
/// Control mode for the SoftBone physics parameters.
/// Slider mode uses a single value for each parameter,
/// Range mode uses a min/max range for each parameter,
/// and Curve mode uses a curve to define the parameter values along the chain.
/// </summary>
[Property, Order( 3 ), Title( "Control Mode" ), Group( "Chain Properties" )]
public SoftBoneControlMode ControlMode
{
get => _controlMode;
set
{
if ( _controlMode == value ) return;
_controlMode = value;
if ( value == SoftBoneControlMode.Curve && !_curveDefaultsInitialized ) InitializeCurveDefaults();
}
}
public enum SoftBoneControlMode
{
Slider,
Range,
Curve
}
/// <summary>
/// When enabled, the root bone itself is also physically simulated
/// (wobbles independently). When disabled, only child bones simulate
/// and the root follows animation exactly.
/// </summary>
[Property, Order( 4 ), Title( "Simulate Root" ), Group( "Chain Properties" )]
public bool SimulateRoot { get; set; } = false;
/// <summary>
/// When enabled, only the first X chain bones are simulated.
/// </summary>
[Property, Order( 5 ), Title( "Limit Chain Length" ), Group( "Chain Properties" )]
public bool LimitChainLength { get; set; } = false;
/// <summary>
/// Limit the amount of chain bones from tip to root that are simulated when Limit Children is enabled.
/// </summary>
[Property, Order( 6 ), Title( "Chain Length Limit" ), Group( "Chain Properties" ), ShowIf( nameof( LimitChainLength ), true )]
public int ChainLimit { get; set; } = 0;
//=========================================================================================================================
// Physics Settings
//=========================================================================================================================
/// <summary>
/// Controls how strongly the chain returns toward its animated rest pose.
/// Higher values reduce swaying and make the chain settle more quickly.
/// </summary>
[Property, Order( 8 ), Range( 0, 1 ), Title( "Drag" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float Pull { get; set; } = 0.20f;
/// <summary>
/// Controls how strongly the chain returns toward its animated rest pose.
/// Higher values reduce swaying and make the chain settle more quickly.
/// </summary>
[Property, Order( 8 ), Range( 0, 1 ), Title( "Drag" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 PullRange { get; set; } = new( 0.20f, 0.20f );
/// <summary>
/// Controls how strongly the chain returns toward its animated rest pose.
/// Higher values reduce swaying and make the chain settle more quickly.
/// </summary>
[Property, Order( 8 ), Title( "Drag" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve PullCurve { get; set; } = ConstantCurve( 0.20f );
/// <summary>
/// Controls how much spring force is applied to the chain.
/// Higher values create softer, bouncier motion, while lower values produce more stable movement.
/// </summary>
[Property, Order( 9 ), Range( 0, 1 ), Title( "Spring" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float Spring { get; set; } = 0.50f;
/// <summary>
/// Controls how much spring force is applied to the chain.
/// Higher values create softer, bouncier motion, while lower values produce more stable movement.
/// </summary>
[Property, Order( 9 ), Range( 0, 1 ), Title( "Spring" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 SpringRange { get; set; } = new( 0.50f, 0.50f );
/// <summary>
/// Controls how much spring force is applied to the chain.
/// Higher values create softer, bouncier motion, while lower values produce more stable movement.
/// </summary>
[Property, Order( 9 ), Title( "Spring" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve SpringCurve { get; set; } = ConstantCurve( 0.50f );
/// <summary>
/// Controls how resistant the chain is to bending.
/// Higher values make the chain feel firmer, while lower values allow more flexibility.
/// </summary>
[Property, Order( 10 ), Range( 0, 1 ), Title( "Stiffness" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float Stiffness { get; set; } = 0.30f;
/// <summary>
/// Controls how resistant the chain is to bending.
/// Higher values make the chain feel firmer, while lower values allow more flexibility.
/// </summary>
[Property, Order( 10 ), Range( 0, 1 ), Title( "Stiffness" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 StiffnessRange { get; set; } = new( 0.30f, 0.30f );
/// <summary>
/// Controls how resistant the chain is to bending.
/// Higher values make the chain feel firmer, while lower values allow more flexibility.
/// </summary>
[Property, Order( 10 ), Title( "Stiffness" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve StiffnessCurve { get; set; } = ConstantCurve( 0.30f );
/// <summary>
/// Applies a constant directional force to the chain.
/// Positive values pull downward, while negative values pull upward.
/// </summary>
[Property, Order( 11 ), Range( -1, 1 ), Title( "Gravity" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float Gravity { get; set; }
/// <summary>
/// Applies a constant directional force to the chain.
/// Positive values pull downward, while negative values pull upward.Positive values pull the chain down, negative values pull the chain up.
/// </summary>
[Property, Order( 11 ), Range( 0, 1 ), Title( "Gravity" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 GravityRange { get; set; }
/// <summary>
/// Applies a constant directional force to the chain.
/// Positive values pull downward, while negative values pull upward.Positive values pull the chain down, negative values pull the chain up.
/// </summary>
[Property, Order( 11 ), Title( "Gravity" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve GravityCurve { get; set; } = ConstantCurve( 0f );
/// <summary>
/// Reduces gravity while the chain is near its animated rest pose.
/// Higher values keep idle chains more stable while preserving gravity during motion.
/// </summary>
[Property, Order( 12 ), Range( 0, 1 ), Title( "Gravity Falloff" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float GravityFalloff { get; set; }
/// <summary>
/// Reduces gravity while the chain is near its animated rest pose.
/// Higher values keep idle chains more stable while preserving gravity during motion.
/// </summary>
[Property, Order( 12 ), Range( 0, 1 ), Title( "Gravity Falloff" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 GravityFalloffRange { get; set; }
/// <summary>
/// Reduces gravity while the chain is near its animated rest pose.
/// Higher values keep idle chains more stable while preserving gravity during motion.
/// </summary>
[Property, Order( 12 ), Title( "Gravity Falloff" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve GravityFalloffCurve { get; set; } = ConstantCurve( 0f );
/// <summary>
/// Controls how much the chain can extend beyond its rest length.
/// Higher values allow longer, softer stretching under force.
/// </summary>
[Property, Order( 13 ), Range( 0, 2 ), Title( "Stretch" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float Stretch { get; set; }
/// <summary>
/// Controls how much the chain can extend beyond its rest length.
/// Higher values allow longer, softer stretching under force.
/// </summary>
[Property, Order( 13 ), Range( 0, 2 ), Title( "Stretch" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 StretchRange { get; set; }
/// <summary>
/// Controls how much the chain can extend beyond its rest length.
/// Higher values allow longer, softer stretching under force.
/// </summary>
[Property, Order( 13 ), Title( "Stretch" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve StretchCurve { get; set; } = ConstantCurve( 0f );
/// <summary>
/// Controls how much the chain can compress below its rest length.
/// Higher values allow greater compression.
/// </summary>
[Property, Order( 14 ), Range( 0, 1 ), Title( "Squish" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float Squish { get; set; }
/// <summary>
/// Controls how much the chain can compress below its rest length.
/// Higher values allow greater compression.
/// </summary>
[Property, Order( 14 ), Range( 0, 1 ), Title( "Squish" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 SquishRange { get; set; }
/// <summary>
/// Controls how much the chain can compress below its rest length.
/// Higher values allow greater compression.
/// </summary>
[Property, Order( 14 ), Title( "Squish" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve SquishCurve { get; set; } = ConstantCurve( 0f );
/// <summary>
/// Reduces movement caused by the character's own motion.
/// Higher values make the chain feel heavier and less reactive while the character moves.
/// </summary>
[Property, Order( 15 ), Range( 0, 1 ), Title( "Movement Resistance" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Slider )]
public float Immobile { get; set; } = 1.00f;
/// <summary>
/// Reduces movement caused by the character's own motion.
/// Higher values make the chain feel heavier and less reactive while the character moves.
/// </summary>
[Property, Order( 15 ), Range( 0, 1 ), Title( "Movement Resistance" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Range )]
public Vector2 ImmobileRange { get; set; } = new( 1.00f, 1.00f );
/// <summary>
/// Reduces movement caused by the character's own motion.
/// Higher values make the chain feel heavier and less reactive while the character moves.
/// </summary>
[Property, Order( 15 ), Title( "Movement Resistance" ), Group( "Physics Settings" ), ShowIf( nameof( ControlMode ), SoftBoneControlMode.Curve )]
public Curve ImmobileCurve { get; set; } = ConstantCurve( 1.00f );
//=========================================================================================================================
// Limit Settings Section
//=========================================================================================================================
/// <summary>
/// Defines how the chain's movement is constrained.
/// Different limit types provide different ways of restricting bone rotation.
/// </summary>
[Property, Order( 16 ), Title( "Limit Type" ), Group( "Limit Settings" )]
public SoftBoneLimitType LimitType { get; set; } = SoftBoneLimitType.Cone;
/// <summary>
/// Maximum angle the chain can rotate away from its rest direction.
/// Higher values allow greater freedom of movement.
/// </summary>
[Property, Order( 17 ), Range( 0, 180 ), Title( "Max Angle" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxAngleSlider ), true )]
public float MaxAngle { get; set; } = 95f;
/// <summary>
/// Maximum angle the chain can rotate away from its rest direction.
/// Higher values allow greater freedom of movement.
/// </summary>
[Property, Order( 17 ), Range( 0, 1 ), Title( "Max Angle" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxAngleRange ), true )]
public Vector2 MaxAngleRange { get; set; } = new( 95f, 95f );
/// <summary>
/// Maximum angle the chain can rotate away from its rest direction.
/// Higher values allow greater freedom of movement.
/// </summary>
[Property, Order( 17 ), Title( "Max Angle" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxAngleCurve ), true )]
public Curve MaxAngleCurve { get; set; } = ConstantCurve( 95f / 180f );
public bool UsesMaxAngle => LimitType == SoftBoneLimitType.Cone || LimitType == SoftBoneLimitType.Plane;
public bool ShowMaxAngleSlider => UsesMaxAngle && ControlMode == SoftBoneControlMode.Slider;
public bool ShowMaxAngleRange => UsesMaxAngle && ControlMode == SoftBoneControlMode.Range;
public bool ShowMaxAngleCurve => UsesMaxAngle && ControlMode == SoftBoneControlMode.Curve;
/// <summary>
/// Maximum horizontal rotation allowed for Ellipse limits.
/// Increase to allow more side-to-side movement.
/// </summary>
[Property, Order( 18 ), Range( 0, 180 ), Title( "Max Yaw" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxYawSlider ), true )]
public float MaxYaw { get; set; } = 95f;
/// <summary>
/// Maximum horizontal rotation allowed for Ellipse limits.
/// Increase to allow more side-to-side movement.
/// </summary>
[Property, Order( 18 ), Range( 0, 180 ), Title( "Max Yaw" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxYawRange ), true )]
public Vector2 MaxYawRange { get; set; } = new( 95f, 95f );
/// <summary>
/// Maximum horizontal rotation allowed for Ellipse limits.
/// Increase to allow more side-to-side movement.
/// </summary>
[Property, Order( 18 ), Title( "Max Yaw" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxYawCurve ), true )]
public Curve MaxYawCurve { get; set; } = ConstantCurve( 95f / 180f );
public bool ShowMaxYawSlider => LimitType == SoftBoneLimitType.Ellipse && ControlMode == SoftBoneControlMode.Slider;
public bool ShowMaxYawRange => LimitType == SoftBoneLimitType.Ellipse && ControlMode == SoftBoneControlMode.Range;
public bool ShowMaxYawCurve => LimitType == SoftBoneLimitType.Ellipse && ControlMode == SoftBoneControlMode.Curve;
/// <summary>
/// Maximum vertical rotation allowed for Ellipse limits.
/// Increase to allow more up-and-down movement.
/// </summary>
[Property, Order( 19 ), Range( 0, 180 ), Title( "Max Pitch" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxPitchSlider ), true )]
public float MaxPitch { get; set; } = 95f;
/// <summary>
/// Maximum vertical rotation allowed for Ellipse limits.
/// Increase to allow more up-and-down movement.
/// </summary>
[Property, Order( 19 ), Range( 0, 180 ), Title( "Max Pitch" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxPitchRange ), true )]
public Vector2 MaxPitchRange { get; set; } = new( 95f, 95f );
/// <summary>
/// Maximum vertical rotation allowed for Ellipse limits.
/// Increase to allow more up-and-down movement.
/// </summary>
[Property, Order( 19 ), Title( "Max Pitch" ), Group( "Limit Settings" ), ShowIf( nameof( ShowMaxPitchCurve ), true )]
public Curve MaxPitchCurve { get; set; } = ConstantCurve( 95f / 180f );
public bool ShowMaxPitchSlider => LimitType == SoftBoneLimitType.Ellipse && ControlMode == SoftBoneControlMode.Slider;
public bool ShowMaxPitchRange => LimitType == SoftBoneLimitType.Ellipse && ControlMode == SoftBoneControlMode.Range;
public bool ShowMaxPitchCurve => ControlMode == SoftBoneControlMode.Curve && LimitType == SoftBoneLimitType.Ellipse;
/// <summary>
/// Rotates the limit shape relative to the chain.
/// </summary>
[Property, Order( 20 ), Title( "Limit Rotation" ), Group( "Limit Settings" ), HideIf( nameof( LimitType ), SoftBoneLimitType.None )]
public Angles LimitRotation { get; set; }
/// <summary>
/// Shape used for this chain's collision volume.
/// Different shapes provide different collision behavior and coverage.
/// </summary>
[Property, Order( 21 ), Title( "Collider Shape" ), Group( "Collision Settings" )]
public SoftBoneLinkColliderShape ColliderShape { get; set; } = SoftBoneLinkColliderShape.Capsule;
/// <summary>
/// Controls which colliders this chain can interact with.
/// Higher modes enable more realistic but more expensive collision behavior.
/// </summary>
[Property, Order( 22 ), Title( "Collision Mode" ), Group( "Collision Settings" )]
public SoftBoneCollisionMode CollisionMode { get; set; } = SoftBoneCollisionMode.BaseCollision;
/// <summary>
/// Assign one or more SoftBone Collider Groups for this chain to interact with.
/// Colliders outside of these groups will be ignored.
/// </summary>
[Property, Order( 23 ), Title( "Collider Groups" ), Group( "Collision Settings" )]
public List<GameObject> ColliderGroups { get; set; } = new();
/// <summary>
/// Allows the colliders to collide with the root collider.
/// Disable if the root should remain free.
/// </summary>
[Property, Order( 24 ), Title( "Collide With Root" ), Group( "Collision Settings" )]
public bool CollideWithRoot { get; set; }
// Helpers for conditional property visibility
private bool ShowRadiusSlider => ControlMode == SoftBoneControlMode.Slider && ColliderShape != SoftBoneLinkColliderShape.Box;
private bool ShowRadiusRange => ControlMode == SoftBoneControlMode.Range && ColliderShape != SoftBoneLinkColliderShape.Box;
private bool ShowRadiusCurve => ControlMode == SoftBoneControlMode.Curve && ColliderShape != SoftBoneLinkColliderShape.Box;
/// <summary>
/// Radius of the chain's collision volume.
/// Larger values create thicker collision boundaries.
/// </summary>
[Property, Order( 25 ), Title( "Collider Radius" ), Group( "Collision Settings" ), ShowIf( nameof( ShowRadiusSlider ), true )]
public float ColliderRadius { get; set; } = 0.5f;
/// <summary>
/// Radius of the chain's collision volume.
/// Larger values create thicker collision boundaries.
/// </summary>
[Property, Order( 25 ), Title( "Collider Radius" ), Group( "Collision Settings" ), ShowIf( nameof( ShowRadiusRange ), true )]
public Vector2 ColliderRadiusRange { get; set; } = new( 0.5f, 0.5f );
/// <summary>
/// Radius of the chain's collision volume.
/// Larger values create thicker collision boundaries.
/// </summary>
[Property, Order( 25 ), Title( "Collider Radius" ), Group( "Collision Settings" ), ShowIf( nameof( ShowRadiusCurve ), true )]
public Curve ColliderRadiusCurve { get; set; } = ConstantCurve( 0.5f );
/// <summary>
/// Height of Capsule collider.
/// Available only when the collider shape is set to Capsule.
/// </summary>
[Property, Order( 26 ), Title( "Collider Height" ), Group( "Collision Settings" ), ShowIf( nameof( ColliderShape ), SoftBoneLinkColliderShape.Capsule )]
public float ColliderHeight { get; set; } = 1.25f;
/// <summary>
/// Defines the dimensions of the box collider.
/// Available only when the collider shape is set to Box.
/// </summary>
[Property, Order( 27 ), Title( "Box Dimensions" ), Group( "Collision Settings" ), ShowIf( nameof( ColliderShape ), SoftBoneLinkColliderShape.Box )]
public Vector3 BoxDimension { get; set; } = new Vector3( 1.25f, 1.25f, 1.25f );
//=========================================================================================================================
// Gizmo Setting Section
//=========================================================================================================================
/// <summary>
/// Displays the chain's angle limit in the Scene view.
/// Useful for visualizing and adjusting movement constraints.
/// </summary>
[Property, Order( 28 ), Header( "Limit Gizmo" ), Title( "Limit Gizmo" ), Group( "Gizmo Settings" )]
public bool ShowLimitGizmo { get; set; } = true;
/// <summary>
/// Keeps the limit gizmo visible even when the chain is not selected.
/// Useful for debugging multiple chains at once.
/// </summary>
[Property, Order( 29 ), Title( "Always Show Gizmo" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowLimitGizmo ), true )]
public bool AlwaysShowLimitGizmo { get; set; }
/// <summary>
/// Color used for rendering the limit gizmo.
/// </summary>
[Property, Order( 30 ), Title( "Limit Gizmo Color" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowLimitGizmo ), true )]
public Color LimitGizmoColor { get; set; } = Color.Orange;
/// <summary>
/// Controls the transparency of the limit gizmo.
/// </summary>
[Property, Order( 31 ), Range( 0, 1 ), Title( "Gizmo Opacity" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowLimitGizmo ), true )]
public float LimitGizmoOpacity { get; set; } = 1f;
/// <summary>
/// Controls the size of the limit gizmo in the Scene view.
/// Adjust if the visualization appears too large or too small.
/// </summary>
[Property, Order( 32 ), Range( 0.1f, 1f ), Title( "Limit Gizmo Size" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowLimitGizmo ), true )]
public float LimitGizmoSize { get; set; } = 0.5f;
/// <summary>
/// Displays the chain's collider in the Scene view.
/// Useful for visualizing collision shapes and sizes.
/// </summary>
[Property, Order( 33 ), Header( "Collider Gizmo" ), Title( "Collider Gizmo" ), Group( "Gizmo Settings" )]
public bool ShowColliderGizmo { get; set; } = true;
/// <summary>
/// Keeps the collider gizmo visible even when the chain is not selected.
/// Useful for debugging collision setups.
/// </summary>
[Property, Order( 34 ), Title( "Always Show Gizmo" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
public bool AlwaysShowGizmo { get; set; }
/// <summary>
/// Color used for rendering the collider gizmo.
/// </summary>
[Property, Order( 35 ), Title( "Collider Gizmo Color" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
public Color ColliderGizmoColor { get; set; } = Gizmo.Colors.Blue;
/// <summary>
/// Controls the transparency of the collider gizmo.
/// </summary>
[Property, Order( 36 ), Range( 0, 1 ), Title( "Gizmo Opacity" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
public float ColliderGizmoOpacity { get; set; } = 1f;
//=========================================================================================================================
// Private States
//=========================================================================================================================
private readonly List<SoftBoneJointState> _joints = new();
private readonly List<BoBiCoSoftBoneCollider> _activeColliders = new();
private readonly List<SoftBoneColliderShape> _externalBodyShapes = new();
private readonly List<SoftBoneColliderShape> _externalChainShapes = new();
private readonly List<SoftBoneChainColliderSnapshot> _publishedChainShapes = new();
private readonly HashSet<BoBiCoSoftBoneCollider> _colliderSet = new();
private readonly List<Transform> _framePose = new();
private readonly List<Transform> _ragdollAnimationPose = new();
private bool _dirty = true;
private bool _usingPhysicsPose;
public IReadOnlyList<SoftBoneJointState> Joints => _joints;
public bool IsDirty => _dirty;
public void MarkDirty() => _dirty = true;
//=========================================================================================================================
// Rebuild Section
//=========================================================================================================================
public bool Rebuild( SkinnedModelRenderer rendererOverride = null )
{
var renderer = rendererOverride;
if ( renderer is null )
return false;
if ( !renderer.IsValid() || renderer.Model?.Bones is null )
return false;
ReleaseCurrentChain( renderer );
var boneName = "";
if ( TargetBone is not null && TargetBone.IsValid() )
boneName = TargetBone.Name;
boneName = boneName?.Trim() ?? "";
if ( string.IsNullOrWhiteSpace( boneName ) || !renderer.Model.Bones.HasBone( boneName ) )
return false;
var current = renderer.Model.Bones.GetBone( boneName );
while ( current is not null )
{
if ( !SoftBoneSkinnedModelUtility.TryGetBoneAnimationLocal( renderer, current, out var animated ) )
break;
var poseVector = GetAnimatedBoneVectorLocal( renderer, current, animated );
var length = Math.Max( poseVector.Length, Epsilon );
var localPoseVector = animated.Rotation.Inverse * poseVector;
var rendererDownLocal = RendererDownLocal( renderer );
var gravityNormalLocal = animated.Rotation.Inverse * rendererDownLocal;
var joint = new SoftBoneJointState
{
Bone = current,
OriginalLocalGravityNormal = gravityNormalLocal,
LocalPoseVector = localPoseVector,
RestTransformLocal = animated,
Length = length,
PreviousBegin = animated.Position,
PreviousEnd = animated.Position + poseVector,
PreviousAnimatedBegin = animated.Position,
PreviousAnimatedRotation = animated.Rotation,
PreviousSimulationRotation = animated.Rotation,
ImmobileEnd = SoftBoneSkinnedModelUtility.ToWorld( renderer, animated.Position + poseVector ),
PreviousEndWorld = SoftBoneSkinnedModelUtility.ToWorld( renderer, animated.Position + poseVector ),
PreviousVector = poseVector,
PreviousVelocity = Vector3.Zero,
HasHistory = false
};
_joints.Add( joint );
if ( current.Children.Count == 0 )
break;
current = current.Children[0];
}
_dirty = false;
return _joints.Count > 0;
}
private void ReleaseCurrentChain( SkinnedModelRenderer renderer )
{
if ( renderer is not null && renderer.IsValid() && renderer.SceneModel.IsValid() )
{
foreach ( var joint in _joints )
{
if ( joint.Bone is not null && renderer.TryGetBoneTransformAnimation( joint.Bone, out var animatedWorld ) )
{
var animatedLocal = renderer.WorldTransform.ToLocal( animatedWorld );
renderer.SceneModel.SetBoneOverride( joint.Bone.Index, in animatedLocal );
}
}
}
_joints.Clear();
_framePose.Clear();
_ragdollAnimationPose.Clear();
_publishedChainShapes.Clear();
SoftBoneCollisionRegistry.Remove( this );
}
private void UpdateOwnerName()
{
if ( GameObject is null || !GameObject.IsValid() )
return;
var desiredName = TargetBone is not null && TargetBone.IsValid()
? TargetBone.Name?.Trim()
: "Empty Chain";
if ( !string.IsNullOrWhiteSpace( desiredName ) && GameObject.Name != desiredName )
GameObject.Name = desiredName;
}
//=========================================================================================================================
// Simulation Section
//=========================================================================================================================
public void Simulate( SkinnedModelRenderer renderer, float frameRatio )
{
// Auto-rebuild if dirty or empty — chain is self-healing so the Group
// doesn't need to orchestrate the Rebuild/Simulate order externally.
if ( _dirty || _joints.Count == 0 )
{
if ( !Rebuild( renderer ) )
return;
}
if ( renderer is null || !renderer.IsValid() || !renderer.SceneModel.IsValid() )
return;
var flag = frameRatio >= 1f;
var rendererDownLocal = RendererDownLocal( renderer );
CollectActiveColliders();
var usePhysicsPose = IsPhysicsDrivingRenderer( renderer );
if ( usePhysicsPose != _usingPhysicsPose )
{
_usingPhysicsPose = usePhysicsPose;
foreach ( var joint in _joints ) joint.HasHistory = false;
if ( usePhysicsPose && !CaptureRagdollAnimationPose( renderer ) )
return;
if ( !usePhysicsPose ) _ragdollAnimationPose.Clear();
}
if ( !CaptureFramePose( renderer, usePhysicsPose ) )
return;
var hasParentEnd = false;
var parentEnd = Vector3.Zero;
var parentAnimatedRotation = Rotation.Identity;
var parentSimulationRotation = Rotation.Identity;
// LimitChainLength: simulate only the last ChainLimit bones (tip-to-root).
// Handled here instead of Rebuild so SimulateRoot logic stays tied to the
// ACTUAL chain root (index 0), not whichever bone happens to be first after slicing.
var startIndex = GetSimulationStartIndex();
ResetExcludedJointsToPose( renderer, startIndex );
for ( var i = startIndex; i < _joints.Count; i++ )
{
var joint = _joints[i];
var chainPosition = GetChainPosition( i );
var pull = EvaluateUnit( Pull, ControlMode, PullRange, PullCurve, chainPosition );
var spring = EvaluateUnit( Spring, ControlMode, SpringRange, SpringCurve, chainPosition );
var stiffness = EvaluateUnit( Stiffness, ControlMode, StiffnessRange, StiffnessCurve, chainPosition );
var immobile = EvaluateUnit( Immobile, ControlMode, ImmobileRange, ImmobileCurve, chainPosition );
var stretch = EvaluateNonNegative( Stretch, ControlMode, StretchRange, StretchCurve, chainPosition );
var squish = EvaluateNonNegative( Squish, ControlMode, SquishRange, SquishCurve, chainPosition );
var gravity = EvaluateSignedUnit( Gravity, ControlMode, GravityRange, GravityCurve, chainPosition );
var gravityFalloff = EvaluateUnit( GravityFalloff, ControlMode, GravityFalloffRange, GravityFalloffCurve, chainPosition );
var colliderRadius = EvaluateColliderRadius( chainPosition );
var maxAngle = EvaluateAngle( MaxAngle, ControlMode, MaxAngleRange, MaxAngleCurve, chainPosition );
var maxYaw = EvaluateAngle( MaxYaw, ControlMode, MaxYawRange, MaxYawCurve, chainPosition );
var maxPitch = EvaluateAngle( MaxPitch, ControlMode, MaxPitchRange, MaxPitchCurve, chainPosition );
var isActualRoot = i == 0;
var isSimulationRoot = i == startIndex;
var isRootSimulated = isActualRoot && SimulateRoot;
var animated = _framePose[i];
var begin = hasParentEnd ? parentEnd : animated.Position;
var animatedPoseVector = GetFramePoseVector( i, joint, animated );
var parentDeflection = isSimulationRoot ? Rotation.Identity : GetParentDeflection( i, parentAnimatedRotation, parentSimulationRotation );
var simulationRotation = parentDeflection * animated.Rotation;
var poseVector = parentDeflection * animatedPoseVector;
if ( isSimulationRoot && RotationOffset != default )
{
var offsetRotation = new Angles( RotationOffset.yaw, RotationOffset.pitch, RotationOffset.roll ).ToRotation();
simulationRotation = animated.Rotation * offsetRotation;
poseVector = simulationRotation * (animated.Rotation.Inverse * animatedPoseVector);
}
var restLength = Math.Max( poseVector.Length, Epsilon );
var minLength = Math.Max( 0f, restLength * (1f - squish) );
var maxLength = Math.Max( minLength, restLength * (1f + stretch) );
var endPoint = joint.HasHistory
? renderer.WorldTransform.PointToLocal( joint.PreviousEndWorld )
: begin + poseVector;
if ( joint.HasHistory )
{
var rootDelta = begin - joint.PreviousAnimatedBegin;
endPoint += rootDelta;
var rotationDelta = animated.Rotation * joint.PreviousAnimatedRotation.Inverse;
endPoint = begin + rotationDelta * (endPoint - begin);
}
// SimulateRoot=false: only skip physics on the TRUE chain root, not on bones that
// happen to be first because LimitChainLength trimmed the start of the iteration.
if ( isActualRoot && !isRootSimulated && !CollideWithRoot )
{
ResetToRestPose( renderer, joint, animated, begin, poseVector, simulationRotation );
parentEnd = joint.PreviousEnd;
parentAnimatedRotation = animated.Rotation;
parentSimulationRotation = joint.PreviousSimulationRotation;
hasParentEnd = true;
continue;
}
if ( pull <= Epsilon )
{
ResetToRestPose( renderer, joint, animated, begin, poseVector, simulationRotation );
if ( !isActualRoot )
ApplySolvedTransform( renderer, joint, animated, simulationRotation );
parentEnd = joint.PreviousEnd;
parentAnimatedRotation = animated.Rotation;
parentSimulationRotation = joint.PreviousSimulationRotation;
hasParentEnd = true;
continue;
}
var previousEndPoint = endPoint;
if ( joint.HasHistory && immobile > Epsilon )
{
var immobileTarget = renderer.WorldTransform.PointToLocal( joint.ImmobileEnd );
endPoint += (immobileTarget - endPoint) * immobile;
}
var gravityAmount = GetGravityAmount( joint, animated, rendererDownLocal, gravity, gravityFalloff );
var gravityTargetEnd = ApplyGravityTarget( begin, poseVector, restLength, gravityAmount, gravity, rendererDownLocal );
var solvedEndPoint = BoBiCoSoftBoneSolver.IntegrateAdvanced(
begin, endPoint, previousEndPoint,
poseVector, joint.PreviousVector, joint.PreviousVelocity,
minLength, maxLength, pull, spring, stiffness, gravityTargetEnd, frameRatio,
out var newVelocity );
var solvedDirection = BoBiCoSoftBoneSolver.SafeNormal( solvedEndPoint - begin, poseVector );
solvedDirection = SoftBoneAngleLimit.Apply( poseVector, solvedDirection, LimitType, maxAngle, maxYaw, maxPitch, LimitRotation );
var solvedLength = Math.Clamp( (solvedEndPoint - begin).Length, minLength, maxLength );
var solvedEnd = begin + solvedDirection * solvedLength;
solvedEnd = ResolveCollisions( renderer, begin, solvedEnd, poseVector, minLength, maxLength, colliderRadius, maxAngle, maxYaw, maxPitch, isActualRoot && CollideWithRoot, out var wasColliding );
solvedDirection = BoBiCoSoftBoneSolver.SafeNormal( solvedEnd - begin, poseVector );
newVelocity = solvedEnd - previousEndPoint;
if ( wasColliding ) newVelocity *= 0.75f;
if ( flag )
{
joint.PreviousEndWorld = SoftBoneSkinnedModelUtility.ToWorld( renderer, solvedEnd );
joint.ImmobileEnd = joint.PreviousEndWorld;
joint.PreviousVector = solvedEnd - begin;
joint.PreviousVelocity = newVelocity;
joint.PreviousAnimatedBegin = begin;
joint.PreviousAnimatedRotation = animated.Rotation;
}
joint.PreviousBegin = begin;
joint.PreviousEnd = solvedEnd;
joint.PreviousSimulationRotation = BoBiCoSoftBoneSolver.SolveHeadRotation( poseVector, solvedEnd - begin, simulationRotation );
joint.HasHistory = true;
if ( !isActualRoot )
ApplySolvedTransform( renderer, joint, animated, joint.PreviousSimulationRotation );
parentEnd = solvedEnd;
parentAnimatedRotation = animated.Rotation;
parentSimulationRotation = joint.PreviousSimulationRotation;
hasParentEnd = true;
}
PublishCollisionShapes( renderer );
}
private void CollectActiveColliders()
{
_activeColliders.Clear();
_colliderSet.Clear();
if ( ColliderGroups is not null )
{
foreach ( var sourceObject in ColliderGroups )
{
if ( sourceObject is null || !sourceObject.IsValid() )
continue;
sourceObject.GetComponent<BoBiCoSoftBoneColliderGroup>()?.CollectColliders( _activeColliders, _colliderSet );
}
}
}
private void ResetExcludedJointsToPose( SkinnedModelRenderer renderer, int startIndex )
{
for ( var i = 0; i < startIndex; i++ )
{
var joint = _joints[i];
var animated = _framePose[i];
var poseVector = GetFramePoseVector( i, joint, animated );
ResetToRestPose( renderer, joint, animated, animated.Position, poseVector, animated.Rotation );
if ( i > 0 )
ApplyAnimationTransform( renderer, joint, animated );
}
}
private Vector3 ResolveCollisions( SkinnedModelRenderer renderer, Vector3 begin, Vector3 solvedEnd, Vector3 poseVector, float minLength, float maxLength, float colliderRadius, float maxAngle, float maxYaw, float maxPitch, bool includeRootHead, out bool collided )
{
collided = false;
if ( CollisionMode == SoftBoneCollisionMode.None || colliderRadius <= Epsilon )
return solvedEnd;
var worldBegin = SoftBoneSkinnedModelUtility.ToWorld( renderer, begin );
var worldEnd = SoftBoneSkinnedModelUtility.ToWorld( renderer, solvedEnd );
foreach ( var collider in _activeColliders )
{
if ( collider.TryGetWorldShape( collider.GetOwningRenderer(), out var shape ) )
collided |= SoftBoneCollisionSolver.Resolve( ref worldEnd, worldBegin, includeRootHead ? worldBegin : GetCollisionStart( worldBegin, worldEnd, colliderRadius ), colliderRadius, minLength, maxLength, shape );
}
if ( CollisionMode == SoftBoneCollisionMode.FullCollision )
{
_externalBodyShapes.Clear();
SoftBoneCollisionRegistry.CollectExternalBodyShapes( renderer, _externalBodyShapes );
foreach ( var shape in _externalBodyShapes )
collided |= SoftBoneCollisionSolver.Resolve( ref worldEnd, worldBegin, includeRootHead ? worldBegin : GetCollisionStart( worldBegin, worldEnd, colliderRadius ), colliderRadius, minLength, maxLength, shape );
}
_externalChainShapes.Clear();
SoftBoneCollisionRegistry.CollectChainShapes( this, renderer, CollisionMode, _externalChainShapes );
foreach ( var shape in _externalChainShapes )
collided |= SoftBoneCollisionSolver.Resolve( ref worldEnd, worldBegin, includeRootHead ? worldBegin : GetCollisionStart( worldBegin, worldEnd, colliderRadius ), colliderRadius, minLength, maxLength, shape );
if ( !collided )
return solvedEnd;
var localDelta = SoftBoneSkinnedModelUtility.ToLocal( renderer, worldEnd ) - begin;
var localDirection = SoftBoneAngleLimit.Apply( poseVector, BoBiCoSoftBoneSolver.SafeNormal( localDelta, poseVector ), LimitType, maxAngle, maxYaw, maxPitch, LimitRotation );
return begin + localDirection * Math.Clamp( localDelta.Length, minLength, maxLength );
}
private Vector3 GetCollisionStart( Vector3 worldBegin, Vector3 worldEnd, float colliderRadius )
{
if ( ColliderShape != SoftBoneLinkColliderShape.Capsule )
return worldEnd;
var axis = worldEnd - worldBegin;
var segmentLength = Math.Max( 0f, ColliderHeight - colliderRadius * 2f );
if ( axis.Length <= Epsilon || segmentLength <= Epsilon )
return worldEnd;
return worldEnd - axis.Normal * Math.Min( axis.Length, segmentLength );
}
private int GetSimulationStartIndex()
{
return LimitChainLength && ChainLimit > 0 && ChainLimit < _joints.Count
? _joints.Count - ChainLimit
: 0;
}
private float GetChainPosition( int index ) => _joints.Count <= 1 ? 0f : index / (float)(_joints.Count - 1);
private static Curve ConstantCurve( float value ) => new( new Curve.Frame( 0f, value ), new Curve.Frame( 1f, value ) );
private void InitializeCurveDefaults()
{
_curveDefaultsInitialized = true;
PullCurve = ConstantCurve( Pull );
SpringCurve = ConstantCurve( Spring );
StiffnessCurve = ConstantCurve( Stiffness );
ImmobileCurve = ConstantCurve( Immobile );
StretchCurve = ConstantCurve( Stretch );
SquishCurve = ConstantCurve( Squish );
GravityCurve = ConstantCurve( Gravity );
GravityFalloffCurve = ConstantCurve( GravityFalloff );
MaxAngleCurve = ConstantCurve( MaxAngle / 180f );
MaxYawCurve = ConstantCurve( MaxYaw / 180f );
MaxPitchCurve = ConstantCurve( MaxPitch / 180f );
ColliderRadiusCurve = ConstantCurve( ColliderRadius );
}
private static float EvaluateUnit( float value, SoftBoneControlMode mode, Vector2 range, Curve curve, float position ) => Math.Clamp( EvaluateControl( value, mode, range, curve, position ), 0f, 1f );
private static float EvaluateSignedUnit( float value, SoftBoneControlMode mode, Vector2 range, Curve curve, float position ) => Math.Clamp( EvaluateControl( value, mode, range, curve, position ), -1f, 1f );
private static float EvaluateNonNegative( float value, SoftBoneControlMode mode, Vector2 range, Curve curve, float position ) => Math.Max( 0f, EvaluateControl( value, mode, range, curve, position ) );
private static float EvaluateAngle( float value, SoftBoneControlMode mode, Vector2 range, Curve curve, float position )
{
if ( mode == SoftBoneControlMode.Curve )
return Math.Clamp( (curve.Length > 0 ? curve.Evaluate( position ) : 1f) * 180f, 0f, 180f );
return Math.Clamp( EvaluateControl( value, mode, range, curve, position ), 0f, 180f );
}
private float EvaluateColliderRadius( float position ) => Math.Max( 0.001f, EvaluateControl( ColliderRadius, ControlMode, ColliderRadiusRange, ColliderRadiusCurve, position ) );
private static float EvaluateControl( float value, SoftBoneControlMode mode, Vector2 range, Curve curve, float position )
{
return mode switch
{
SoftBoneControlMode.Range => range.x + (range.y - range.x) * position,
SoftBoneControlMode.Curve => curve.Length > 0 ? curve.Evaluate( position ) : value,
_ => value
};
}
internal void PublishCollisionShapes( SkinnedModelRenderer renderer )
{
if ( CollisionMode == SoftBoneCollisionMode.None )
{
SoftBoneCollisionRegistry.Remove( this );
return;
}
_publishedChainShapes.Clear();
for ( var i = GetSimulationStartIndex(); i < _joints.Count; i++ )
{
if ( i == 0 && !CollideWithRoot )
continue;
var joint = _joints[i];
if ( !joint.HasHistory )
continue;
var begin = SoftBoneSkinnedModelUtility.ToWorld( renderer, joint.PreviousBegin );
var end = SoftBoneSkinnedModelUtility.ToWorld( renderer, joint.PreviousEnd );
var radius = EvaluateColliderRadius( GetChainPosition( i ) );
var direction = BoBiCoSoftBoneSolver.SafeNormal( end - begin, Vector3.Forward );
SoftBoneColliderShape shape;
switch ( ColliderShape )
{
case SoftBoneLinkColliderShape.Sphere:
shape = SoftBoneColliderShape.Sphere( begin, radius );
break;
case SoftBoneLinkColliderShape.Box:
shape = SoftBoneColliderShape.Box( begin, Rotation.FromToRotation( Vector3.Forward, direction ), new Vector3( BoxDimension.z, BoxDimension.x, BoxDimension.y ) );
break;
case SoftBoneLinkColliderShape.Flat:
shape = SoftBoneColliderShape.Flat( begin, direction );
break;
default:
var halfLine = Math.Max( 0f, ColliderHeight * 0.5f - radius );
shape = SoftBoneColliderShape.Capsule( begin - direction * halfLine, begin + direction * halfLine, radius );
break;
}
_publishedChainShapes.Add( new SoftBoneChainColliderSnapshot( renderer, shape ) );
}
SoftBoneCollisionRegistry.Publish( this, renderer, _publishedChainShapes );
}
//=========================================================================================================================
// Helpers
//=========================================================================================================================
private bool CaptureFramePose( SkinnedModelRenderer renderer, bool usePhysicsPose )
{
_framePose.Clear();
Transform rootWorld = default;
if ( !renderer.TryGetBoneTransform( _joints[0].Bone, out rootWorld )
&& !renderer.TryGetBoneTransformAnimation( _joints[0].Bone, out rootWorld ) )
return false;
var rootPose = renderer.WorldTransform.ToLocal( rootWorld );
_framePose.Add( rootPose );
for ( var i = 1; i < _joints.Count; i++ )
{
var joint = _joints[i];
Transform animationPose;
if ( usePhysicsPose )
{
if ( _ragdollAnimationPose.Count != _joints.Count ) return false;
animationPose = ApplyRootPoseDelta( _ragdollAnimationPose[i], _ragdollAnimationPose[0], rootPose );
}
else
{
if ( !renderer.TryGetBoneTransformAnimation( joint.Bone, out var worldTransform ) )
return false;
animationPose = renderer.WorldTransform.ToLocal( worldTransform );
}
_framePose.Add( animationPose );
}
return true;
}
private bool CaptureRagdollAnimationPose( SkinnedModelRenderer renderer )
{
_ragdollAnimationPose.Clear();
foreach ( var joint in _joints )
{
if ( !renderer.TryGetBoneTransformAnimation( joint.Bone, out var worldTransform ) )
{
_ragdollAnimationPose.Clear();
return false;
}
_ragdollAnimationPose.Add( renderer.WorldTransform.ToLocal( worldTransform ) );
}
return true;
}
private static Transform ApplyRootPoseDelta( Transform pose, Transform animationRoot, Transform finalRoot )
{
var rotationDelta = finalRoot.Rotation * animationRoot.Rotation.Inverse;
var positionDelta = finalRoot.Position - rotationDelta * animationRoot.Position;
return new Transform( positionDelta + rotationDelta * pose.Position, rotationDelta * pose.Rotation, pose.Scale );
}
private bool IsPhysicsDrivingRenderer( SkinnedModelRenderer renderer )
{
if ( Scene is null ) return false;
foreach ( var physics in Scene.GetAllComponents<ModelPhysics>() )
if ( physics is not null && physics.Enabled && physics.MotionEnabled && physics.Renderer == renderer )
return true;
return false;
}
private bool TryGetSolveTransformLocal( SkinnedModelRenderer renderer, SoftBoneJointState joint, out Transform transform )
{
if ( renderer is not null && renderer.IsValid() && joint.Bone is not null
&& renderer.TryGetBoneTransformAnimation( joint.Bone, out var worldTransform ) )
{
transform = renderer.WorldTransform.ToLocal( worldTransform );
return true;
}
transform = joint.RestTransformLocal;
return joint.RestTransformLocal.Scale != Vector3.Zero;
}
private static Vector3 GetAnimatedBoneVectorLocal( SkinnedModelRenderer renderer, BoneCollection.Bone bone, Transform animated )
{
if ( bone.Children.Count > 0 && SoftBoneSkinnedModelUtility.TryGetBoneAnimationLocal( renderer, bone.Children[0], out var child ) )
{
var direction = child.Position - animated.Position;
if ( direction.Length > Epsilon )
return direction;
}
var fallback = animated.Rotation * Vector3.Forward;
return fallback.Length > Epsilon ? fallback : Vector3.Forward;
}
private Vector3 GetFramePoseVector( int index, SoftBoneJointState joint, Transform pose )
{
if ( index + 1 < _framePose.Count )
{
var direction = _framePose[index + 1].Position - pose.Position;
if ( direction.Length > Epsilon ) return direction;
}
return GetPoseVectorLocal( joint, pose );
}
private static Vector3 GetPoseVectorLocal( SoftBoneJointState joint, Transform animated )
{
var poseVector = animated.Rotation * joint.LocalPoseVector;
if ( poseVector.Length > Epsilon )
return poseVector;
var fallback = animated.Rotation * Vector3.Forward;
return fallback.Length > Epsilon ? fallback : Vector3.Forward;
}
private static Rotation GetParentDeflection( int index, Rotation parentAnimatedRotation, Rotation parentSimulationRotation )
{
if ( index <= 0 )
return Rotation.Identity;
return parentSimulationRotation * parentAnimatedRotation.Inverse;
}
private float GetGravityAmount( SoftBoneJointState joint, Transform animated, Vector3 rendererDownLocal, float gravity, float gravityFalloff )
{
if ( Math.Abs( gravity ) <= Epsilon )
return 0f;
var gravityAmount = Math.Abs( gravity );
if ( gravityFalloff > Epsilon )
{
var gravityNormal = animated.Rotation * joint.OriginalLocalGravityNormal;
var gravityBias = Math.Min( 1f, 1f - Vector3.Dot(
BoBiCoSoftBoneSolver.SafeNormal( gravityNormal, rendererDownLocal ),
rendererDownLocal ) );
gravityAmount *= (1f - gravityFalloff) + gravityFalloff * gravityBias;
}
return Math.Clamp( gravityAmount, 0f, 1f );
}
private static Vector3 ApplyGravityTarget( Vector3 begin, Vector3 poseVector, float restLength, float gravityAmount, float gravity, Vector3 rendererDownLocal )
{
if ( gravityAmount <= Epsilon )
return begin + poseVector;
var gravityVector = (gravity > 0f ? rendererDownLocal : -rendererDownLocal) * restLength;
var targetVector = poseVector + (gravityVector - poseVector) * gravityAmount;
return begin + BoBiCoSoftBoneSolver.SafeNormal( targetVector, poseVector ) * restLength;
}
private static void ResetToRestPose( SkinnedModelRenderer renderer, SoftBoneJointState joint, Transform animated, Vector3 begin, Vector3 poseVector, Rotation simulationRotation )
{
var restEnd = begin + poseVector;
joint.PreviousBegin = begin;
joint.PreviousEnd = restEnd;
joint.PreviousAnimatedBegin = begin;
joint.PreviousAnimatedRotation = animated.Rotation;
joint.PreviousSimulationRotation = simulationRotation;
joint.ImmobileEnd = SoftBoneSkinnedModelUtility.ToWorld( renderer, restEnd );
joint.PreviousEndWorld = joint.ImmobileEnd;
joint.PreviousVector = poseVector;
joint.PreviousVelocity = Vector3.Zero;
joint.HasHistory = true;
}
private static void ApplySolvedTransform( SkinnedModelRenderer renderer, SoftBoneJointState joint, Transform animated, Rotation solvedRotation )
{
var localTransform = new Transform( joint.PreviousBegin, solvedRotation, animated.Scale );
renderer.SceneModel.SetBoneOverride( joint.Bone.Index, in localTransform );
}
private static void ApplyAnimationTransform( SkinnedModelRenderer renderer, SoftBoneJointState joint, Transform animated )
{
renderer.SceneModel.SetBoneOverride( joint.Bone.Index, in animated );
}
private static Vector3 RendererDownLocal( SkinnedModelRenderer renderer )
{
if ( renderer is null || !renderer.IsValid() )
return Vector3.Down;
return BoBiCoSoftBoneSolver.SafeNormal( renderer.WorldTransform.Rotation.Inverse * Vector3.Down, Vector3.Down );
}
//=========================================================================================================================
// Building Gizmo
//=========================================================================================================================
protected override void DrawGizmos()
{
base.DrawGizmos();
var drawCollider = CollisionMode != SoftBoneCollisionMode.None && ShowColliderGizmo && (AlwaysShowGizmo || Gizmo.IsSelected);
var drawLimit = ShowLimitGizmo && (AlwaysShowLimitGizmo || Gizmo.IsSelected) && LimitType != SoftBoneLimitType.None;
if ( !drawCollider && !drawLimit )
return;
var renderer = FindRendererForGizmo();
if ( renderer is null || !renderer.IsValid() )
return;
if ( _dirty || _joints.Count == 0 )
Rebuild( renderer );
using ( Transform.DisableProxy() )
{
Gizmo.Transform = new Transform( Vector3.Zero, Rotation.Identity, 1f );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 1;
for ( var i = GetSimulationStartIndex(); i < _joints.Count; i++ )
{
var joint = _joints[i];
if ( !TryGetSolveTransformLocal( renderer, joint, out var animated ) )
continue;
var solvedBegin = joint.HasHistory ? joint.PreviousBegin : animated.Position;
var solvedEnd = joint.HasHistory ? joint.PreviousEnd : animated.Position + GetPoseVectorLocal( joint, animated );
var physicsBegin = SoftBoneSkinnedModelUtility.ToWorld( renderer, solvedBegin );
var boneObject = renderer.GetBoneObject( joint.Bone );
var begin = boneObject is not null && boneObject.IsValid()
? boneObject.WorldPosition
: physicsBegin;
var end = SoftBoneSkinnedModelUtility.ToWorld( renderer, solvedEnd );
// Root bone: only draw when SimulateRoot is enabled.
// Non-root bones: skip leaf/endpoint bones (Children.Count == 0) since their
// endpoint is a synthetic forward poseVector, not a real child position.
// The Children.Count gate must NOT apply to the simulated root — it may be a
// leaf or near-leaf bone and still needs collision when SimulateRoot is on.
var drawJointCollider = drawCollider
&& (i != 0 || (SimulateRoot && CollideWithRoot));
if ( drawJointCollider )
{
Gizmo.Draw.Color = ColliderGizmoColor.WithAlpha( ColliderGizmoOpacity );
DrawLinkColliderGizmo( begin, end, EvaluateColliderRadius( GetChainPosition( i ) ) );
}
var drawJointLimit = drawLimit && (i != 0 || SimulateRoot);
if ( drawJointLimit )
{
var solvedVector = solvedEnd - solvedBegin;
var chainPosition = GetChainPosition( i );
var colliderRadius = EvaluateColliderRadius( chainPosition );
DrawLimitGizmo( renderer, physicsBegin, solvedVector, Math.Max( joint.Length, colliderRadius * 2f ) * LimitGizmoSize, EvaluateAngle( MaxAngle, ControlMode, MaxAngleRange, MaxAngleCurve, chainPosition ), EvaluateAngle( MaxYaw, ControlMode, MaxYawRange, MaxYawCurve, chainPosition ), EvaluateAngle( MaxPitch, ControlMode, MaxPitchRange, MaxPitchCurve, chainPosition ) );
}
}
}
}
private void DrawLinkColliderGizmo( Vector3 worldStart, Vector3 worldEnd, float colliderRadius )
{
if ( ColliderShape == SoftBoneLinkColliderShape.Sphere )
{
Gizmo.Draw.LineSphere( new Sphere( worldStart, colliderRadius ) );
return;
}
if ( ColliderShape == SoftBoneLinkColliderShape.Capsule )
{
var direction = BoBiCoSoftBoneSolver.SafeNormal( worldEnd - worldStart, Vector3.Forward );
var halfLine = Math.Max( 0f, ColliderHeight * 0.5f - colliderRadius );
Gizmo.Draw.LineCapsule( new Capsule( worldStart - direction * halfLine, worldStart + direction * halfLine, colliderRadius ) );
return;
}
var forward = BoBiCoSoftBoneSolver.SafeNormal( worldEnd - worldStart, Vector3.Forward );
var right = BoBiCoSoftBoneSolver.SafeNormal( Vector3.Cross( forward, Vector3.Up ), Vector3.Right );
var up = BoBiCoSoftBoneSolver.SafeNormal( Vector3.Cross( right, forward ), Vector3.Up );
if ( ColliderShape == SoftBoneLinkColliderShape.Flat )
{
var half = colliderRadius;
var a = worldStart + (right + up) * half; var b = worldStart + (right - up) * half;
var c = worldStart + (-right - up) * half; var d = worldStart + (-right + up) * half;
Gizmo.Draw.Line( a, b ); Gizmo.Draw.Line( b, c ); Gizmo.Draw.Line( c, d ); Gizmo.Draw.Line( d, a );
Gizmo.Draw.Line( worldStart, worldStart + forward * Math.Max( colliderRadius, 0.2f ) );
return;
}
var halfSize = BoxDimension * 0.5f;
var corners = new Vector3[8];
for ( var corner = 0; corner < 8; corner++ )
corners[corner] = worldStart
+ forward * (((corner & 1) == 0 ? -1f : 1f) * halfSize.z)
+ right * (((corner & 2) == 0 ? -1f : 1f) * halfSize.x)
+ up * (((corner & 4) == 0 ? -1f : 1f) * halfSize.y);
int[] edges = { 0, 1, 0, 2, 0, 4, 1, 3, 1, 5, 2, 3, 2, 6, 3, 7, 4, 5, 4, 6, 5, 7, 6, 7 };
for ( var edge = 0; edge < edges.Length; edge += 2 ) Gizmo.Draw.Line( corners[edges[edge]], corners[edges[edge + 1]] );
}
private void DrawLimitGizmo( SkinnedModelRenderer renderer, Vector3 worldBegin, Vector3 solvedVector, float radius, float maxAngle, float maxYaw, float maxPitch )
{
SoftBoneAngleLimit.GetAxes( solvedVector, LimitRotation, out var axisX, out var axisY, out var axisZ );
axisX = renderer.WorldTransform.Rotation * axisX;
axisY = renderer.WorldTransform.Rotation * axisY;
axisZ = renderer.WorldTransform.Rotation * axisZ;
var gizmoRotation = Rotation.FromAxis( axisY, 90f );
axisX = gizmoRotation * axisX;
axisZ = gizmoRotation * axisZ;
Gizmo.Draw.Color = LimitGizmoColor.WithAlpha( LimitGizmoOpacity );
const int steps = 24;
if ( LimitType == SoftBoneLimitType.Cone )
{
DrawArc( worldBegin, axisY, axisX, maxAngle, radius, steps );
DrawArc( worldBegin, axisY, axisZ, maxAngle, radius, steps );
}
else if ( LimitType == SoftBoneLimitType.Plane )
{
DrawArc( worldBegin, axisY, axisZ, maxAngle, radius, steps );
Gizmo.Draw.Line( worldBegin - axisX * radius * 0.45f, worldBegin + axisX * radius * 0.45f );
}
else
{
DrawArc( worldBegin, axisY, axisX, maxYaw, radius, steps );
DrawArc( worldBegin, axisY, axisZ, maxPitch, radius, steps );
}
}
private static void DrawArc( Vector3 origin, Vector3 forward, Vector3 side, float angle, float radius, int steps )
{
var previous = origin + Rotation.FromAxis( side, -angle ) * forward * radius;
for ( var i = 1; i <= steps; i++ )
{
var point = origin + Rotation.FromAxis( side, -angle + angle * 2f * i / steps ) * forward * radius;
Gizmo.Draw.Line( previous, point );
previous = point;
}
}
private SkinnedModelRenderer FindRendererForGizmo()
{
var targetRenderer = TargetBone?.GetComponentInParent<SkinnedModelRenderer>( true, true );
if ( targetRenderer is not null && targetRenderer.IsValid() )
return targetRenderer;
return null;
}
internal SkinnedModelRenderer GetTargetRenderer() => FindRendererForGizmo();
protected override void OnValidate()
{
UpdateOwnerName();
ColliderGizmoOpacity = Math.Clamp( ColliderGizmoOpacity, 0f, 1f );
LimitGizmoOpacity = Math.Clamp( LimitGizmoOpacity, 0f, 1f );
ColliderHeight = Math.Max( ColliderHeight, 0.002f );
BoxDimension = new Vector3( Math.Max( BoxDimension.x, 0.001f ), Math.Max( BoxDimension.y, 0.001f ), Math.Max( BoxDimension.z, 0.001f ) );
LimitGizmoSize = Math.Max( LimitGizmoSize, 0.1f );
MarkDirty();
}
protected override void OnDisabled()
{
SoftBoneCollisionRegistry.Remove( this );
}
}