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.
//=========================================================================================================================
// 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 );
}
}
}