A component for S&box that defines a soft-bone collider attached to a target bone. It exposes editor properties for shape, size, offsets and gizmo display, computes world-space collider shapes (sphere, capsule, box, flat) from a bone transform, and draws debug gizmos in the editor/scene.
//=========================================================================================================================
// BoBiCo Soft-Bone Collider Component
//=========================================================================================================================
using System;
[Title( "SoftBone Collider" )]
[Category( "BoBiCo" )]
[Icon( "radio_button_unchecked" )]
[HelpUrl( "https://bobico-lab.vercel.app/softbone-docs/bobico-softbones/core-components/softbone-collider" )]
public sealed class BoBiCoSoftBoneCollider : Component, Component.ExecuteInEditor
{
public enum ColliderShape
{
/// <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,
}
//=========================================================================================================================
// Collider Properties
//=========================================================================================================================
/// <summary>
/// Offsets the position of the collider.
/// </summary>
[Property, Order( 1 ), Title( "Position Offset" ), Group( "Collider Properties" )]
public Vector3 PositionOffset { get; set; }
/// <summary>
/// Offsets the rotation of the collider.
/// </summary>
[Property, Order( 2 ), Title( "Rotation Offset" ), Group( "Collider Properties" )]
public Angles RotationOffset { get; set; }
/// <summary>
/// Target bone to which the collider is attached.
/// </summary>
[Property, Order( 3 ), Title( "Target Bone" ), Group( "Collider Properties" )]
public GameObject TargetBone { get; set; }
//=========================================================================================================================
// Shape Settings
//=========================================================================================================================
/// <summary>
/// Shape used for this chain's collision volume.
/// Different shapes provide different collision behavior and coverage.
/// </summary>
[Property, Order( 4 ), Title( "Collider Shape" ), Group( "Shape Settings" )]
public ColliderShape Shape { get; set; } = ColliderShape.Sphere;
/// <summary>
/// Radius of the chain's collision volume.
/// Larger values create thicker collision boundaries.
/// </summary>
[Property, Order( 5 ), Title( "Collider Radius" ), Group( "Shape Settings" ), HideIf( nameof( Shape ), ColliderShape.Box )]
public float ColliderRadius { get; set; } = 2f;
/// <summary>
/// Height of Capsule collider.
/// </summary>
[Property, Order( 6 ), Title( "Collider Height" ), Group( "Shape Settings" ), ShowIf( nameof( Shape ), ColliderShape.Capsule )]
public float ColliderHeight { get; set; } = 10f;
/// <summary>
/// Size of Box colliders.
/// Only applies when the collider shape is set to Box.
/// </summary>
[Property, Order( 7 ), Title( "Box Dimension" ), Group( "Shape Settings" ), ShowIf( nameof( Shape ), ColliderShape.Box )]
public Vector3 BoxSize { get; set; } = new Vector3( 1.25f, 1.25f, 1.25f );
//=========================================================================================================================
// Collider Gizmo Settings
//=========================================================================================================================
/// <summary>
/// Displays the chain's collider in the Scene view.
/// Useful for visualizing collision shapes and sizes.
/// </summary>
[Property, Order( 8 ), 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( 9 ), 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( 10 ), Title( "Collider Gizmo Color" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
public Color ColliderGizmoColor { get; set; } = Gizmo.Colors.Green;
/// <summary>
/// Controls the transparency of the collider gizmo.
/// Lower values make the visualization less intrusive.
/// </summary>
[Property, Order( 11 ), Range( 0, 1 ), Title( "Gizmo Opacity" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
public float ColliderGizmoOpacity { get; set; } = 1f;
//=========================================================================================================================
// Collider Shape Calculation
//=========================================================================================================================
public bool TryGetWorldShape( SkinnedModelRenderer renderer, out SoftBoneColliderShape shape )
{
shape = default;
var boneName = TargetBone is not null && TargetBone.IsValid() ? TargetBone.Name?.Trim() : "";
if ( string.IsNullOrWhiteSpace( boneName ) )
return false;
var hasBoneTransform = renderer is not null && renderer.IsValid() && renderer.Model?.Bones is not null && renderer.Model.Bones.HasBone( boneName );
if ( hasBoneTransform )
{
var bone = renderer.Model.Bones.GetBone( boneName );
if ( renderer.TryGetBoneTransformAnimation( bone, out var animatedTransform ) || renderer.TryGetBoneTransform( bone, out animatedTransform ) )
return BuildWorldShape( animatedTransform, out shape );
}
return BuildWorldShape( TargetBone.WorldTransform, out shape );
}
private bool BuildWorldShape( Transform boneTransform, out SoftBoneColliderShape shape )
{
shape = default;
// Swap pitch/yaw to match S&box bone forward = X axis
var fixedRotOffset = new Angles( RotationOffset.roll, RotationOffset.pitch, RotationOffset.yaw );
var rotation = boneTransform.Rotation * fixedRotOffset.ToRotation();
var radius = Math.Max( ColliderRadius, 0.001f );
var height = Math.Max( ColliderHeight, radius * 2f );
// Collider offsets use the existing VRC-style axis order: X=up, Y=forward, Z=right.
var remappedOffset = new Vector3( PositionOffset.y, PositionOffset.z, PositionOffset.x );
var center = boneTransform.PointToWorld( remappedOffset );
if ( Shape == ColliderShape.Sphere || (Shape == ColliderShape.Capsule && height <= radius * 2f) )
{
shape = SoftBoneColliderShape.Sphere( center, radius );
return true;
}
if ( Shape == ColliderShape.Capsule )
{
var halfLine = Math.Max( 0f, height * 0.5f - radius );
// VRC capsules are authored along their local Up axis. Using Forward here
// made S&box Roll rotate around the capsule's own length, which is invisible.
var axis = rotation * Vector3.Forward;
shape = SoftBoneColliderShape.Capsule( center - axis * halfLine, center + axis * halfLine, radius );
}
else if ( Shape == ColliderShape.Box )
shape = SoftBoneColliderShape.Box( center, rotation, new Vector3( Math.Max( BoxSize.z, 0.001f ), Math.Max( BoxSize.x, 0.001f ), Math.Max( BoxSize.y, 0.001f ) ) );
else
shape = SoftBoneColliderShape.Flat( center, rotation * Vector3.Up );
return true;
}
//=========================================================================================================================
// Collider Gizmo Drawing
//=========================================================================================================================
protected override void DrawGizmos()
{
base.DrawGizmos();
DrawColliderGizmo( AlwaysShowGizmo || Gizmo.IsSelected );
}
internal void DrawColliderGizmo( bool visible, bool force = false )
{
if ( !visible || ( !force && !ShowColliderGizmo ) || !TryGetWorldShape( FindRenderer(), out var shape ) )
return;
// Draw directly in world space so the sphere radius is never distorted by
// the component's scale (which caused the squished ellipsoid).
using ( Gizmo.Scope( "SoftBoneCollider" ) )
{
Gizmo.Transform = new Transform( Vector3.Zero, Rotation.Identity, 1f );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 1;
Gizmo.Draw.Color = ColliderGizmoColor.WithAlpha( ColliderGizmoOpacity );
if ( shape.IsSphere )
Gizmo.Draw.LineSphere( new Sphere( shape.Start, shape.Radius ) );
else if ( shape.Type == SoftBoneColliderShapeType.Capsule )
Gizmo.Draw.LineCapsule( new Capsule( shape.Start, shape.End, shape.Radius ) );
else if ( shape.Type == SoftBoneColliderShapeType.Box )
{
Gizmo.Transform = new Transform( shape.Center, shape.Rotation, 1f );
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( Vector3.Zero, shape.Size ) );
}
else
{
var tangent = Vector3.Cross( shape.Normal, Math.Abs( Vector3.Dot( shape.Normal, Vector3.Up ) ) < 0.99f ? Vector3.Up : Vector3.Right ).Normal;
var bitangent = Vector3.Cross( shape.Normal, tangent ).Normal;
var size = ColliderRadius;
var a = shape.Center + (tangent + bitangent) * size;
var b = shape.Center + (tangent - bitangent) * size;
var c = shape.Center + (-tangent - bitangent) * size;
var d = shape.Center + (-tangent + bitangent) * size;
Gizmo.Draw.Line( a, b ); Gizmo.Draw.Line( b, c ); Gizmo.Draw.Line( c, d ); Gizmo.Draw.Line( d, a );
Gizmo.Draw.Line( shape.Center, shape.Center + shape.Normal * 8f );
}
}
}
//=========================================================================================================================
// Collider Bone Lookup
//=========================================================================================================================
private SkinnedModelRenderer FindRenderer()
{
var targetRenderer = TargetBone?.GetComponentInParent<SkinnedModelRenderer>( true, true );
if ( targetRenderer is not null && targetRenderer.IsValid() )
return targetRenderer;
return null;
}
internal SkinnedModelRenderer GetOwningRenderer() => FindRenderer();
protected override void OnValidate()
{
ColliderRadius = Math.Max( ColliderRadius, 0.001f );
ColliderHeight = Math.Max( ColliderHeight, ColliderRadius * 2f );
BoxSize = new Vector3( Math.Max( BoxSize.x, 0.001f ), Math.Max( BoxSize.y, 0.001f ), Math.Max( BoxSize.z, 0.001f ) );
ColliderGizmoOpacity = Math.Clamp( ColliderGizmoOpacity, 0f, 1f );
}
}