Editor/Effigy/Rig/SkinWeights.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>One bone's influence over one vertex.</summary>
public readonly struct BoneWeight
{
public readonly int Bone;
public readonly float Weight;
public BoneWeight( int bone, float weight )
{
Bone = bone;
Weight = weight;
}
public override string ToString() => $"{Bone}:{Weight:0.###}";
}
/// <summary>
/// Per-vertex bone influences, parallel to PolyMesh.Positions.
///
/// INFLUENCE COUNT IS UNBOUNDED HERE, ON PURPOSE. Engines want four; subdivision and weight
/// smoothing both produce more than four as a matter of course, and clamping at every intermediate
/// step throws away weight that later steps would have used, so the result depends on how many
/// times you happened to smooth. Keeping authoring lossless and pruning once, at export, makes the
/// output a function of the input instead. See Prune.
///
/// The invariant everything else relies on: every vertex's weights are non-negative and sum to 1.
/// Blend preserves it for any affine combination, which is exactly what Catmull-Clark is made of —
/// see the note in CatmullClark about why that is not a lucky accident.
/// </summary>
public sealed class SkinWeights
{
public List<BoneWeight[]> Vertices = new();
public int Count => Vertices.Count;
public SkinWeights() { }
public SkinWeights( int vertexCount )
{
for ( var i = 0; i < vertexCount; i++ )
Vertices.Add( Array.Empty<BoneWeight>() );
}
public BoneWeight[] this[int i]
{
get => Vertices[i];
set => Vertices[i] = value;
}
/// <summary>Everything weighted to one bone. What a static model exports as.</summary>
public static SkinWeights AllTo( int vertexCount, int bone )
{
var w = new SkinWeights();
var single = new[] { new BoneWeight( bone, 1f ) };
for ( var i = 0; i < vertexCount; i++ )
w.Vertices.Add( single );
return w;
}
/// <summary>
/// Combine several weighted influence sets into one — the operation subdivision, smoothing and
/// interpolation are all made of.
///
/// If the incoming coefficients sum to 1 and each input is a partition of unity, so is the
/// result. That is the whole reason this is the only way weights are ever combined.
/// </summary>
public static BoneWeight[] Blend( IEnumerable<(BoneWeight[] Weights, float Coefficient)> terms )
{
var acc = new Dictionary<int, float>();
foreach ( var (weights, k) in terms )
{
if ( weights is null || k == 0f )
continue;
foreach ( var bw in weights )
{
acc.TryGetValue( bw.Bone, out var existing );
acc[bw.Bone] = existing + bw.Weight * k;
}
}
return Finish( acc );
}
public static BoneWeight[] Blend( params (BoneWeight[] Weights, float Coefficient)[] terms ) =>
Blend( (IEnumerable<(BoneWeight[], float)>)terms );
/// <summary>
/// Drop to at most `max` influences per vertex and renormalise.
///
/// Called once, at export, because engines index a fixed number of bones per vertex — four,
/// almost universally. Dropping the smallest influences and rescaling the survivors is the
/// standard approach and is visually invisible at four: the fifth influence on a Catmull-Clark
/// vertex is worth well under a percent.
/// </summary>
public static BoneWeight[] Prune( BoneWeight[] weights, int max )
{
if ( max <= 0 )
throw new ArgumentOutOfRangeException( nameof( max ) );
if ( weights is null || weights.Length == 0 )
return Array.Empty<BoneWeight>();
// SORTED EVEN WHEN NOTHING IS DROPPED. SmdWriter takes weights[0] as the vertex's parent
// bone, on the documented understanding that the strongest influence comes first — so
// returning a short set in whatever order it arrived exports the wrong parent bone. A vertex
// weighted [(0, 0.2), (1, 0.8)] would name bone 0 as its parent.
//
// Copying rather than returning the caller's array matters for the same reason it always
// does: a caller that prunes and then edits the result would otherwise be editing the mesh's
// own weights through the back door.
if ( weights.Length <= max )
return weights.OrderByDescending( w => w.Weight ).ToArray();
var kept = weights.OrderByDescending( w => w.Weight ).Take( max ).ToArray();
var total = kept.Sum( w => w.Weight );
if ( total <= 1e-9f )
return new[] { new BoneWeight( kept[0].Bone, 1f ) };
for ( var i = 0; i < kept.Length; i++ )
kept[i] = new BoneWeight( kept[i].Bone, kept[i].Weight / total );
return kept;
}
/// <summary>
/// Normalise, drop negligible and negative influences, and order strongest first. Shared by
/// Blend and the binders so there is one definition of a tidy weight set.
///
/// THE THRESHOLD IS RELATIVE, AND HAS TO BE. It runs on raw accumulator values, before
/// normalising, and those are not on any fixed scale: inverse-distance weighting on a 100-unit
/// model with a falloff of 4 produces influences around 1e-7. An absolute epsilon discards every
/// one of them, the vertex comes out with no influences at all, and export silently pins the
/// whole mesh to bone 0 — which is exactly what happened before this comment existed. Scaling
/// the threshold to the total makes the result depend on the shape of the weights rather than on
/// how big the model happens to be.
/// </summary>
internal static BoneWeight[] Finish( Dictionary<int, float> acc, float relativeEpsilon = 1e-6f )
{
var total = 0f;
foreach ( var v in acc.Values )
{
if ( v > 0f )
total += v;
}
if ( total <= 0f )
return Array.Empty<BoneWeight>();
var floor = total * relativeEpsilon;
var keptTotal = 0f;
foreach ( var v in acc.Values )
{
if ( v > floor )
keptTotal += v;
}
if ( keptTotal <= 0f )
return Array.Empty<BoneWeight>();
var result = new List<BoneWeight>( acc.Count );
foreach ( var (bone, v) in acc )
{
if ( v > floor )
result.Add( new BoneWeight( bone, v / keptTotal ) );
}
result.Sort( ( a, b ) => b.Weight.CompareTo( a.Weight ) );
return result.ToArray();
}
public SkinWeights Clone()
{
var s = new SkinWeights();
foreach ( var w in Vertices )
s.Vertices.Add( (BoneWeight[])w.Clone() );
return s;
}
/// <summary>
/// What is wrong with these weights, as data rather than an exception — same posture as
/// MeshValidator, and for the same reason: mid-edit states are allowed to be briefly invalid.
/// </summary>
public List<string> Validate( int vertexCount, int boneCount )
{
var errors = new List<string>();
if ( Vertices.Count != vertexCount )
errors.Add( $"{Vertices.Count} weight sets for {vertexCount} vertices" );
for ( var i = 0; i < Vertices.Count; i++ )
{
var w = Vertices[i];
if ( w.Length == 0 )
{
errors.Add( $"vertex {i} is unweighted" );
continue;
}
var sum = 0f;
foreach ( var bw in w )
{
if ( bw.Bone < 0 || bw.Bone >= boneCount )
errors.Add( $"vertex {i} references bone {bw.Bone}, out of range" );
if ( bw.Weight < 0f )
errors.Add( $"vertex {i} has a negative weight on bone {bw.Bone}" );
sum += bw.Weight;
}
if ( MathF.Abs( sum - 1f ) > 1e-3f )
errors.Add( $"vertex {i} weights sum to {sum:0.####}, not 1" );
}
return errors;
}
}