Code/BoBiCo Soft-Physics/Components/BoBiCoSoftBoneGroup.cs

Component that groups and manages multiple BoBiCoSoftBoneChain instances. It resolves a target SkinnedModelRenderer, rebuilds chains, publishes collision shapes, and advances per-frame soft-bone simulation using a fixed-step accumulator with optional distance-based culling.

Native Interop
//=========================================================================================================================
// BoBiCo Soft-Bone Group Component
//=========================================================================================================================

using System;
using System.Collections.Generic;

[Title( "SoftBone Group" )]
[Category( "BoBiCo" )]
[Icon( "account_tree" )]
[HelpUrl( "https://bobico-lab.vercel.app/softbone-docs/bobico-softbones/core-components/softbone-group" )]
public sealed class BoBiCoSoftBoneGroup : Component, Component.ExecuteInEditor
{
	private const int MaxFixedSteps = 6;

//=========================================================================================================================
// Group Setup
//=========================================================================================================================

	/// <summary>
	/// Assign one or more SoftBone Chains for this group to manage and simulate.
    /// Chains outside of the group will be ignored.
	/// </summary>
	[Property, Title( "SoftBone Chains" ), Group( "Chains" )]
	public List<GameObject> Chains { get; set; } = new();

	// ── Settings ───────────────────────────────────────────────────────────────

	/// <summary>
	/// Physics update rate in Hz. 
	/// Lower values are cheaper and laggy; higher values are smoother.
	/// </summary>
	[Property, Range( 15, 240 ), Title( "Update Rate (Hz)" ), Group( "Group Settings" )]
	public float UpdateRate { get; set; } = 120f;

	/// <summary>
	/// Skip simulation entirely when the main camera is further than this distance.
	/// Bones freeze in place until the camera comes back within range.
	/// </summary>
	[Property, Title( "Disable In Far Distance" ), Group( "Group Settings" )]
	public bool DisableFarDistance { get; set; } = false;

	/// <summary>
	/// The distance at which the simulation is disabled.
	/// </summary>
	[Property, Title( "Far Distance" ), Group( "Group Settings" ), ShowIf( nameof( DisableFarDistance ), true )]
	public float FarDistance { get; set; } = 1000f;

//=========================================================================================================================
// Private States & Functions
//=========================================================================================================================

	private SkinnedModelRenderer _resolvedRenderer;
	private IDisposable _updateBonesHook;
	private bool _dirty = true;
	private float _accumulatedTime;

	public void MarkDirty() => _dirty = true;

	protected override void OnEnabled()
	{
		_dirty = true;
		_accumulatedTime = 0f;
		RegisterUpdateBonesHook();
		EnsureSetup( true );
	}

	protected override void OnDisabled()
	{
		SetCollisionCulled( false );
		_updateBonesHook?.Dispose();
		_updateBonesHook = null;
		_resolvedRenderer = null;
		_accumulatedTime = 0f;
	}

	protected override void OnValidate()
	{
		Chains ??= new List<GameObject>();
		UpdateRate = Math.Clamp( UpdateRate, 15f, 240f );
		FarDistance = Math.Max( FarDistance, 0f );
		_dirty = true;
	}

	protected override void OnUpdate()
	{
		if ( _updateBonesHook is not null || !EnsureSetup() )
			return;

		RunWithAccumulator();
	}

	private void RegisterUpdateBonesHook()
	{
		_updateBonesHook?.Dispose();
		_updateBonesHook = Scene?.AddHook(
			GameObjectSystem.Stage.UpdateBones,
			1,
			() => UpdateBones(),
			nameof( BoBiCoSoftBoneGroup ),
			"Solve SoftBone chains after animation" );
	}

	private void UpdateBones()
	{
		if ( !Enabled || !EnsureSetup() )
			return;

		RunWithAccumulator();
	}

	
	private void SimulateAll( float frameRatio )
	{
		var renderer = _resolvedRenderer;
		if ( renderer is null || !renderer.IsValid() )
			return;

		foreach ( var chainObject in Chains )
		{
			if ( chainObject is null || !chainObject.IsValid() )
				continue;

			var chain = chainObject.GetComponent<BoBiCoSoftBoneChain>();
			if ( chain is null || !chain.Enabled )
				continue;

			if ( _dirty || chain.Joints.Count == 0 || chain.IsDirty )
				chain.Rebuild( renderer );

			chain.PublishCollisionShapes( renderer );
		}

		SoftBoneCollisionRegistry.BeginStep( Scene );

		foreach ( var chainObject in Chains )
		{
			if ( chainObject is null || !chainObject.IsValid() )
				continue;

			var chain = chainObject.GetComponent<BoBiCoSoftBoneChain>();
			if ( chain is null || !chain.Enabled )
				continue;

			if ( _dirty || chain.Joints.Count == 0 || chain.IsDirty )
				chain.Rebuild( renderer );

			chain.Simulate( renderer, frameRatio );
		}

		_dirty = false;
	}

	private bool EnsureSetup( bool force = false )
	{
		if ( !force && !_dirty && _resolvedRenderer is not null && _resolvedRenderer.IsValid() )
			return true;

		_resolvedRenderer = ResolveRenderer();

		if ( _resolvedRenderer is null || !_resolvedRenderer.IsValid() )
			return false;

		Chains ??= new List<GameObject>();

		foreach ( var chainObject in Chains )
		{
			if ( chainObject is null || !chainObject.IsValid() )
				continue;

			chainObject.GetComponent<BoBiCoSoftBoneChain>()?.Rebuild( _resolvedRenderer );
		}

		_dirty = false;
		return true;
	}

	private SkinnedModelRenderer ResolveRenderer()
	{
		Chains ??= new List<GameObject>();
		foreach ( var chainObject in Chains )
		{
			if ( chainObject is null || !chainObject.IsValid() )
				continue;

			var renderer = chainObject.GetComponent<BoBiCoSoftBoneChain>()?.GetTargetRenderer();
			if ( renderer is not null && renderer.IsValid() )
				return renderer;
		}

		return null;
	}


//=========================================================================================================================
// Bone & Collision Culling
//=========================================================================================================================

	private Vector3? GetMainCameraPosition()
	{
		foreach ( var cam in Scene.GetAllComponents<CameraComponent>() )
		{
			if ( cam.IsMainCamera )
				return cam.WorldPosition;
		}
		return null;
	}

	private void RunWithAccumulator()
	{
		var culled = false;
		if ( DisableFarDistance )
		{
			var camPos = GetMainCameraPosition();
			if ( camPos.HasValue )
			{
				var dist = Vector3.DistanceBetween( WorldPosition, camPos.Value );
				culled = dist > FarDistance;
			}
		}

		SetCollisionCulled( culled );
		if ( culled )
		{
			_accumulatedTime = 0f;
			return;
		}

		var rate = Math.Clamp( UpdateRate, 15f, 120f );
		_accumulatedTime += Time.Delta;

		var executions = Math.Min( _accumulatedTime * rate, MaxFixedSteps );
		var numSteps = (int)MathF.Ceiling( executions );

		if ( numSteps <= 1 )
		{
			SimulateAll( executions );
		}
		else
		{
			for ( var step = 0; step < numSteps; step++ )
				SimulateAll( Math.Min( 1f, executions - step ) );
		}

		if ( executions >= 1f )
			_accumulatedTime = (executions - MathF.Floor( executions )) / rate;
	}

	private void SetCollisionCulled( bool culled )
	{
		SoftBoneCollisionRegistry.SetRendererCulled( _resolvedRenderer, culled );
		if ( !culled ) return;

		foreach ( var chainObject in Chains )
		{
			var chain = chainObject?.GetComponent<BoBiCoSoftBoneChain>();
			if ( chain is not null ) SoftBoneCollisionRegistry.Remove( chain );
		}
	}
}