Effigy.Tests/SoftBoneTests.cs
using System;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Verification for the soft bone solver.
///
/// A wobble that is subtly wrong still looks like a wobble. Nothing in here looks at anything - it
/// measures the properties that a plausible-looking but broken solver would violate: bone lengths
/// changing, a still rig drifting, a chain that never settles, a bone leaving its cone, a result
/// that depends on the frame rate. Every one of those is invisible in a viewport and obvious in
/// arithmetic, which is the whole reason this solver was put in the kernel.
/// </summary>
public static class SoftBoneTests
{
public static void Run()
{
Section( "a rigid rig is left exactly alone" );
TestRigidUntouched();
Section( "bone lengths survive a solve" );
TestLengthsPreserved();
Section( "a still rig settles and stays still" );
TestSettles();
Section( "a moved rig lags, then catches up" );
TestLag();
Section( "the cone is never exceeded" );
TestCone();
Section( "stiffness is frame-rate independent" );
TestFrameRate();
Section( "softness accumulates down a chain" );
TestChain();
Section( "the solver is deterministic and refuses bad input" );
TestContract();
Section( "nonsense softness is reported rather than silently ignored" );
TestDiagnostics();
}
// ---------------------------------------------------------------- fixtures
/// <summary>
/// A three-bone chain running up +Y, one unit per bone. Soft from the second bone on, so there
/// is always a rigid root to measure the soft part against.
/// </summary>
static Skeleton Chain( float stiffness = 8f, float damping = 0.04f, float maxAngle = 180f )
{
var skeleton = new Skeleton();
int a = skeleton.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, 0 ), new Vec3( 0, 1, 0 ) );
int b = skeleton.AddBoneFromPoints( "mid", a, new Vec3( 0, 1, 0 ), new Vec3( 0, 2, 0 ) );
skeleton.AddBoneFromPoints( "tip", b, new Vec3( 0, 2, 0 ), new Vec3( 0, 3, 0 ) );
for ( int i = 1; i < skeleton.Count; i++ )
{
skeleton.Bones[i].Soft = new SoftBone
{
Stiffness = stiffness,
Damping = damping,
MaxAngle = maxAngle,
Weight = 0f,
};
}
return skeleton;
}
/// <summary>Run the solver to rest, so a test measures the settled state rather than the swing.</summary>
static void Settle( Skeleton skeleton, SoftPose pose, int steps = 400, float dt = 1f / 60f )
{
for ( int i = 0; i < steps; i++ )
{
var world = SoftSolver.BindPose( skeleton );
SoftSolver.Solve( skeleton, world, pose, dt, Vec3.Zero );
}
}
static float BoneLength( Skeleton skeleton, Xform[] world, int i )
{
var bone = skeleton.Bones[i];
var head = world[i].Origin;
var tail = head + world[i].Y.Normal * bone.Length;
return (tail - head).Length;
}
// ---------------------------------------------------------------- tests
/// <summary>
/// The property that makes the feature adoptable: a rig with no SoftBone anywhere comes back
/// bit-identical. If this fails, turning the solver on changes every existing model.
/// </summary>
static void TestRigidUntouched()
{
var skeleton = Chain();
foreach ( var bone in skeleton.Bones ) bone.Soft = null;
var expected = SoftSolver.BindPose( skeleton );
var actual = SoftSolver.BindPose( skeleton );
var pose = new SoftPose( skeleton.Count );
for ( int step = 0; step < 20; step++ )
SoftSolver.Solve( skeleton, actual, pose, 1f / 60f );
bool same = true;
for ( int i = 0; i < skeleton.Count; i++ )
same &= actual[i].Origin.AlmostEquals( expected[i].Origin, 1e-6f )
&& actual[i].Y.AlmostEquals( expected[i].Y, 1e-6f );
Check( "a rig with no soft bones is unchanged by the solver", same );
}
/// <summary>
/// The classic failure of every spring-based rig: it looks right and the limb is 6% longer.
/// Driven hard - big gravity, low stiffness - because that is when a spring solver stretches.
/// </summary>
static void TestLengthsPreserved()
{
var skeleton = Chain( stiffness: 0.5f, damping: 0.9f );
foreach ( var bone in skeleton.Bones ) if ( bone.Soft is not null ) bone.Soft.Weight = 4f;
var pose = new SoftPose( skeleton.Count );
float worst = 0f;
for ( int step = 0; step < 200; step++ )
{
var world = SoftSolver.BindPose( skeleton );
SoftSolver.Solve( skeleton, world, pose, 1f / 60f );
for ( int i = 0; i < skeleton.Count; i++ )
worst = MathF.Max( worst, MathF.Abs( BoneLength( skeleton, world, i ) - skeleton.Bones[i].Length ) );
}
Check( "no bone stretched under gravity", worst < 1e-4f, $"worst error {worst:0.######}" );
}
/// <summary>
/// With no gravity and nothing moving, the solved pose must BE the bind pose - not near it, and
/// not slowly leaving it. A solver that drifts here turns a static model into one that sags
/// over the first ten seconds of every scene.
/// </summary>
static void TestSettles()
{
var skeleton = Chain();
var pose = new SoftPose( skeleton.Count );
Settle( skeleton, pose );
var bind = SoftSolver.BindPose( skeleton );
var world = SoftSolver.BindPose( skeleton );
SoftSolver.Solve( skeleton, world, pose, 1f / 60f, Vec3.Zero );
float drift = 0f;
for ( int i = 0; i < skeleton.Count; i++ )
drift = MathF.Max( drift, (world[i].Origin - bind[i].Origin).Length );
Check( "a still rig does not drift off its bind pose", drift < 1e-4f, $"drift {drift:0.######}" );
}
/// <summary>
/// The feature itself. Move the root, and the soft bones must NOT arrive with it - that lag is
/// the whole point - but they must arrive eventually, or the limb has come off.
/// </summary>
static void TestLag()
{
var skeleton = Chain( stiffness: 6f );
var pose = new SoftPose( skeleton.Count );
Settle( skeleton, pose );
// Swing the root a quarter turn about Z, so every bone above it should be left behind.
var turn = Xform.Rotate( new Vec3( 0, 0, 1 ), MathF.PI * 0.5f );
Xform[] Animated()
{
var world = SoftSolver.BindPose( skeleton );
for ( int i = 0; i < world.Length; i++ ) world[i] = turn * world[i];
return world;
}
var target = Animated();
var first = Animated();
SoftSolver.Solve( skeleton, first, pose, 1f / 60f, Vec3.Zero );
float lag = (first[2].Origin - target[2].Origin).Length;
Check( "the tip lags behind a sudden turn", lag > 0.05f, $"lag {lag:0.####}" );
for ( int step = 0; step < 600; step++ )
{
var world = Animated();
SoftSolver.Solve( skeleton, world, pose, 1f / 60f, Vec3.Zero );
}
var settled = Animated();
SoftSolver.Solve( skeleton, settled, pose, 1f / 60f, Vec3.Zero );
float remaining = (settled[2].Origin - target[2].Origin).Length;
Check( "and catches up once the motion stops", remaining < 1e-3f, $"remaining {remaining:0.######}" );
}
/// <summary>
/// A limit that can be outrun is not a limit. Driven with gravity far past what the stiffness
/// could resist, which is exactly the case a spring-based "limit" fails.
/// </summary>
static void TestCone()
{
const float limit = 20f;
var skeleton = Chain( stiffness: 0.1f, damping: 0.9f, maxAngle: limit );
foreach ( var bone in skeleton.Bones ) if ( bone.Soft is not null ) bone.Soft.Weight = 20f;
var pose = new SoftPose( skeleton.Count );
float worstOwn = 0f;
float worstTotal = 0f;
var bind = SoftSolver.BindPose( skeleton );
for ( int step = 0; step < 300; step++ )
{
var world = SoftSolver.BindPose( skeleton );
SoftSolver.Solve( skeleton, world, pose, 1f / 60f );
for ( int i = 1; i < skeleton.Count; i++ )
{
// The invariant the solver actually enforces is per bone, against the rest
// direction it was given - which comes from its SOLVED parent, not from the bind
// pose. Recomputed here the same way, because measuring against the bind pose
// measures something else: see the accumulation check below.
var parent = skeleton.Bones[i].Parent;
var rest = parent >= 0 ? world[parent] * skeleton.Bones[i].Local : world[i];
worstOwn = MathF.Max( worstOwn, Angle( world[i].Y, rest.Y ) );
worstTotal = MathF.Max( worstTotal, Angle( world[i].Y, bind[i].Y ) );
}
}
// A hair over for float error; the failure this catches is tens of degrees.
Check( $"no bone strays more than {limit} degrees from its own parent",
worstOwn <= limit + 0.5f, $"worst {worstOwn:0.##} deg" );
// Cones COMPOUND, and that is correct rather than a leak: a chain of bones each 20 degrees
// off its parent is 40 degrees off the bind pose at the second joint, the same way a real
// chain is. What must hold is that it is bounded by the chain rather than unbounded.
int softCount = skeleton.Count - 1;
Check( "total deviation stays within the chain's worth of cones",
worstTotal <= limit * softCount + 0.5f, $"total {worstTotal:0.##} deg over {softCount} soft bones" );
}
static float Angle( Vec3 a, Vec3 b ) =>
MathF.Acos( Math.Clamp( Vec3.Dot( a.Normal, b.Normal ), -1f, 1f ) ) * 180f / MathF.PI;
/// <summary>
/// The same swing at 30fps and at 240fps must end up in the same place. A solver that lerps by
/// a per-frame fraction passes every visual check and makes the rig floppier on slow machines,
/// which then gets blamed on the art.
/// </summary>
static void TestFrameRate()
{
Vec3 After( float dt, float seconds )
{
var skeleton = Chain( stiffness: 6f );
var pose = new SoftPose( skeleton.Count );
Settle( skeleton, pose, 400, dt );
var turn = Xform.Rotate( new Vec3( 0, 0, 1 ), MathF.PI * 0.5f );
Xform[] world = null;
for ( int step = 0; step < (int)(seconds / dt); step++ )
{
world = SoftSolver.BindPose( skeleton );
for ( int i = 0; i < world.Length; i++ ) world[i] = turn * world[i];
SoftSolver.Solve( skeleton, world, pose, dt, Vec3.Zero );
}
return world[2].Origin;
}
var slow = After( 1f / 30f, 0.25f );
var fast = After( 1f / 240f, 0.25f );
float gap = (slow - fast).Length;
// Verlet with a changing step is not exactly step-invariant, so this is a tolerance rather
// than an equality - but a per-frame lerp lands these a whole bone apart.
Check( "30fps and 240fps agree on where the tip got to", gap < 0.08f, $"gap {gap:0.####}" );
}
/// <summary>
/// The tip must lag more than the middle: each bone's head follows its parent's SOLVED
/// transform, so softness compounds down the chain. A solver that reads the animated parent
/// instead gives every bone the same lag, which reads as a rig sliding rather than swinging.
/// </summary>
static void TestChain()
{
var skeleton = Chain( stiffness: 5f );
var pose = new SoftPose( skeleton.Count );
Settle( skeleton, pose );
var turn = Xform.Rotate( new Vec3( 0, 0, 1 ), MathF.PI * 0.5f );
var target = SoftSolver.BindPose( skeleton );
for ( int i = 0; i < target.Length; i++ ) target[i] = turn * target[i];
var world = SoftSolver.BindPose( skeleton );
for ( int i = 0; i < world.Length; i++ ) world[i] = turn * world[i];
SoftSolver.Solve( skeleton, world, pose, 1f / 60f, Vec3.Zero );
float mid = (world[1].Origin - target[1].Origin).Length;
float tip = (world[2].Origin - target[2].Origin).Length;
Check( "the tip lags further than the bone below it", tip > mid, $"mid {mid:0.####}, tip {tip:0.####}" );
}
/// <summary>
/// Same inputs, same outputs - a rig that wobbles differently on replay cannot be tested by
/// anyone. And the argument checks, because a mismatched array is a silent read of the wrong
/// bone rather than a crash.
/// </summary>
static void TestContract()
{
Vec3 Once()
{
var skeleton = Chain();
var pose = new SoftPose( skeleton.Count );
var world = SoftSolver.BindPose( skeleton );
for ( int step = 0; step < 50; step++ )
{
world = SoftSolver.BindPose( skeleton );
SoftSolver.Solve( skeleton, world, pose, 1f / 60f );
}
return world[2].Origin;
}
Check( "two runs agree exactly", Once().Equals( Once() ) );
var small = Chain();
Check( "a short transform array is refused",
Throws( () => SoftSolver.Solve( small, new Xform[1], new SoftPose( small.Count ), 1f / 60f ) ) );
Check( "a pose for the wrong bone count is refused",
Throws( () => SoftSolver.Solve( small, SoftSolver.BindPose( small ), new SoftPose( 1 ), 1f / 60f ) ) );
// A paused editor hands over dt = 0 every frame. That is not an error and must not move
// anything or divide by anything.
var still = Chain();
var stillPose = new SoftPose( still.Count );
var stillWorld = SoftSolver.BindPose( still );
SoftSolver.Solve( still, stillWorld, stillPose, 0f );
Check( "a zero timestep is a no-op rather than a NaN",
!float.IsNaN( stillWorld[2].Origin.x ) );
Check( "cone with a 180 degree limit changes nothing",
SoftSolver.Cone( new Vec3( 1, 0, 0 ), new Vec3( 0, 1, 0 ), 180f ).AlmostEquals( new Vec3( 1, 0, 0 ) ) );
// Directly opposed: there is no plane the two share, and the naive cross product is zero.
var opposed = SoftSolver.Cone( new Vec3( 0, -1, 0 ), new Vec3( 0, 1, 0 ), 30f );
float angle = MathF.Acos( Math.Clamp( Vec3.Dot( opposed, new Vec3( 0, 1, 0 ) ), -1f, 1f ) ) * 180f / MathF.PI;
Check( "cone handles an exactly opposed direction", MathF.Abs( angle - 30f ) < 0.5f, $"{angle:0.##} deg" );
}
/// <summary>
/// The solver clamps bad numbers rather than throwing, because a slider passes through invalid
/// on its way to valid. That makes every one of these silent, which is why they have to be
/// caught somewhere - and the point of catching them is that the bone LOOKS soft in a panel
/// while being rigid on screen.
/// </summary>
static void TestDiagnostics()
{
bool Reports( Action<SoftBone> break_, string fragment )
{
var skeleton = Chain();
break_( skeleton.Bones[1].Soft );
foreach ( var problem in RigDiagnostics.Check( skeleton ) )
{
if ( problem.Problem.Contains( fragment, StringComparison.OrdinalIgnoreCase ) )
return true;
}
return false;
}
Check( "negative stiffness is reported", Reports( s => s.Stiffness = -5f, "negative stiffness" ) );
Check( "damping outside 0 to 1 is reported", Reports( s => s.Damping = 4f, "damping outside" ) );
Check( "a zero cone is reported", Reports( s => s.MaxAngle = 0f, "zero cone" ) );
Check( "a cone over 180 is reported", Reports( s => s.MaxAngle = 400f, "over 180" ) );
// A clean rig must stay clean, or the check is noise that gets ignored.
var good = Chain();
bool quiet = true;
foreach ( var problem in RigDiagnostics.Check( good ) )
quiet &= !problem.Problem.Contains( "Soft bone", StringComparison.Ordinal );
Check( "a sane soft rig reports nothing", quiet );
}
static bool Throws( Action action )
{
try { action(); return false; }
catch { return true; }
}
static void Section( string title ) => Report.Section( title );
static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}