Editor/Core/Rigging/Skinning.cs
namespace HumanoidRigger.EditorTools.Core.Rigging;
using Vector3 = System.Numerics.Vector3;
/// <summary>Region-constrained envelopes initialize a screened graph diffusion solve.
/// Mesh edges carry smoothing; disconnected surfaces cannot exchange weights.</summary>
public static class Skinning
{
// Scoped to one immutable fitted skeleton; never retained across marker edits.
internal sealed class SolveCache
{
internal readonly ImportedCharacter Surface;
internal readonly VolumeEvidence Volume;
internal readonly Dictionary<bool,GraphField> Fields=[];
internal SurfaceHugging? Hugging;
// Measured limb boundaries for this skeleton, and trunk membership in
// the merged vertex order.
internal TrunkRegion? Trunk;internal bool[]? TrunkVertices;internal SkinningLocality? Locality;
internal SolveCache(ImportedCharacter character)
{
Surface=new(){Meshes=[Geometry.Merge(character.Meshes)]};
Volume=new(character.Meshes.Where(m=>m.Kind!=MeshKind.Accessory));
}
}
internal sealed record GraphField((int Node,float Length,float Conductance)[][] Edges,float[] Denominators,float[][] Distances,float[] Nearest,float[][] Geodesic);
public static Influence[][][] Solve(ImportedCharacter character,GeneratedRig rig,bool useLocalityPrior=false,bool useRegionSeeds=false)
=>Solve(character,rig,new SolveCache(character),useLocalityPrior,useRegionSeeds);
internal static Influence[][][] Solve(ImportedCharacter character,GeneratedRig rig,SolveCache cache,bool useLocalityPrior=false,bool useRegionSeeds=false)
{
if(cache.Trunk is null)
{
cache.Trunk=new(character,rig);cache.TrunkVertices=cache.Trunk.Vertices.SelectMany(part=>part).ToArray();
cache.Locality=new(character,rig,RigGeometry.SegmentEnds(rig));
}
// Material boundaries must not create separate skinning domains. Preserve
// the original vertex ordering so weights can be split back without loss.
var weights=SolveSurface(cache.Surface,rig,cache.Volume,character.AnatomicalHeight,useLocalityPrior,useRegionSeeds,cache)[0];int offset=0;
return character.Meshes.Select(m=>{var part=weights.Skip(offset).Take(m.Vertices.Length).ToArray();offset+=m.Vertices.Length;return part;}).ToArray();
}
static Influence[][][] SolveSurface(ImportedCharacter character,GeneratedRig rig,VolumeEvidence volume,float height,bool useLocalityPrior,bool useRegionSeeds,SolveCache cache)
{
var bones=rig.Bones;var result=new Influence[character.Meshes.Length][][];
var ends=RigGeometry.SegmentEnds(rig);
var pelvis=bones.First(b=>b.Role=="Pelvis").Position;
for(int part=0;part<character.Meshes.Length;part++)
{
var mesh=character.Meshes[part];var count=mesh.Vertices.Length;
var field=new float[count][];var seeds=new float[count][];
if(!cache.Fields.TryGetValue(useRegionSeeds,out var graph))
{
var neighbors=Geometry.Neighbors(mesh,height*(useRegionSeeds?1e-5f:1e-6f));
// Geometry stays fixed throughout the distance solve and diffusion.
// Retain neighbor order so cached coefficients preserve summation order.
var edges=neighbors.Select((row,v)=>row.Select(n=>
{
float length=Vector3.Distance(mesh.Vertices[v],mesh.Vertices[n]);
return(Node:n,Length:length,Conductance:1/Math.Max(length,height*.001f));
}).ToArray()).ToArray();
var denominators=edges.Select(row=>{float sum=0;foreach(var edge in row)sum+=edge.Conductance;return sum;}).ToArray();
// Regions give disconnected surfaces independent seeding baselines.
// The ordinary solve shares one, so that a tiny eye or prop close to
// a bone cannot suppress seeds on the head or body it belongs to.
var regions=Geometry.Components(neighbors);
var components=useRegionSeeds?regions:new int[count];int componentCount=components.Max()+1;
var digits=SkinRegions.DetachedDigits(mesh,neighbors,rig.Anatomy);
// A bone lies under the skin it moves. Where a hand rests against a
// thigh, the thigh's surface is nearer the hand bone than its own,
// yet faces it: the bone is on the outside. Penalize that as the
// heat solve does; an open surface has no normal and is left alone.
var normals=SkinningNormals.ClosedSurface(mesh.Vertices,mesh.Triangles);
var distances=new float[count][];var nearest=new float[count];var facing=new float[count][];
for(int v=0;v<count;v++)
{
distances[v]=new float[bones.Length];nearest[v]=float.PositiveInfinity;facing[v]=new float[bones.Length];Array.Fill(facing[v],1f);
for(int b=0;b<bones.Length;b++)
{
var p=mesh.Vertices[v];var role=ReferenceFitting.Owner(rig,b);
var distance=!bones[b].Deform || !SkinRegions.Allows(digits[v],role) || role.EndsWith(".L")&&p.X<pelvis.X-height*.025f || role.EndsWith(".R")&&p.X>pelvis.X+height*.025f
?float.PositiveInfinity:Vector3.Distance(p,Geometry.ClosestOnSegment(p,bones[b].Position,ends[b]));
if(float.IsFinite(distance) && volume.InteriorCells>0)
distance+=6*volume.ExteriorLength(Geometry.ClosestOnSegment(p,bones[b].Position,ends[b]),p);
if(float.IsFinite(distance)&&distance>height*1e-5f&&normals[v]!=Vector3.Zero)
{
float alignment=Vector3.Dot(normals[v],(p-Geometry.ClosestOnSegment(p,bones[b].Position,ends[b]))/distance);
if(float.IsFinite(alignment)){facing[v][b]=1/Math.Max(.05f,(1+alignment)*.5f);distance*=facing[v][b];}
}
distances[v][b]=distance;nearest[v]=Math.Min(nearest[v],distance);
}
}
var geodesic=new float[bones.Length][];
// A shell that lies a cloth's thickness outside the body, such as
// separate shorts, is farther from every bone than the skin beneath
// and never seeds under the shared baseline. Left unreached, it
// would fall back to straight-line distance and take a hand's weight
// onto the hip it rests against. Seed such orphans on their own.
bool[]? orphan=null;
void TraceBone(int b)
{
var values=Enumerable.Repeat(float.PositiveInfinity,count).ToArray();var queue=new PriorityQueue<int,float>();
float band=Math.Clamp(Vector3.Distance(bones[b].Position,ends[b])*.04f,height*.001f,height*.005f);
// The regional candidate gives disconnected surfaces independent baselines.
// A tiny eye/prop close to a bone must not suppress seeds on the
// larger head or body; those fields cannot reach one another.
// A limb's thickness varies along its bone. Measure the nearest
// surface around each stretch of the segment: against one global
// minimum only the thinnest ring would seed, leaving a deltoid
// or upper thigh to a neighboring bone. Surface far outside its
// own stretch, such as a flank beside the shoulder, still cannot seed.
const int stretches=8;
var segment=ends[b]-bones[b].Position;float segmentLength=segment.LengthSquared();
int Stretch(int v)=>segmentLength<=0?0:Math.Clamp((int)(Vector3.Dot(mesh.Vertices[v]-bones[b].Position,segment)/segmentLength*stretches),0,stretches-1);
var surfaceDistance=Enumerable.Repeat(float.PositiveInfinity,componentCount*stretches).ToArray();
var own=orphan is null?null:Enumerable.Repeat(float.PositiveInfinity,(regions.Max()+1)*stretches).ToArray();
// Only eligible points define a region's minimum; a closer point
// owned by another bone can otherwise eliminate every valid seed.
for(int v=0;v<count;v++)if(!useRegionSeeds||distances[v][b]<=nearest[v]+band)
{
int at=components[v]*stretches+Stretch(v);surfaceDistance[at]=Math.Min(surfaceDistance[at],distances[v][b]);
if(own is not null&&orphan![regions[v]]&&distances[v][b]<=nearest[v]+band){int mine=regions[v]*stretches+Stretch(v);own[mine]=Math.Min(own[mine],distances[v][b]);}
}
for(int v=0;v<count;v++)
{
float baseline=own is not null&&orphan![regions[v]]?own[regions[v]*stretches+Stretch(v)]:surfaceDistance[components[v]*stretches+Stretch(v)];
if(float.IsFinite(distances[v][b])&&distances[v][b]<=nearest[v]+band && distances[v][b]<=baseline+height*.015f)
{values[v]=distances[v][b];queue.Enqueue(v,values[v]);}
}
while(queue.TryDequeue(out var v,out float distance))
{
if(distance>values[v])continue;
foreach(var edge in edges[v])
{
// A field crossing onto surface that faces its bone from the
// outside, a hoodie side fused to its sleeve or a thigh under
// a hand, pays what its seeds pay.
int n=edge.Node;float next=distance+edge.Length*facing[n][b];
if(next<values[n]){values[n]=next;queue.Enqueue(n,next);}
}
}
geodesic[b]=values;
}
int traceWorkers=RigWork.WorkerCount(count);
RigWork.For(bones.Length,traceWorkers,TraceBone);
if(!useRegionSeeds)
{
var reachedRegion=new bool[regions.Max()+1];
for(int v=0;v<count;v++)if(!reachedRegion[regions[v]]&&geodesic.Any(g=>float.IsFinite(g[v])))reachedRegion[regions[v]]=true;
if(reachedRegion.Any(r=>!r))
{
orphan=reachedRegion.Select(r=>!r).ToArray();
RigWork.For(bones.Length,traceWorkers,TraceBone);
}
}
graph=new(edges,denominators,distances,nearest,geodesic);cache.Fields.Add(useRegionSeeds,graph);
}
var claim=new float[bones.Length];var fade=new float[bones.Length];
for(int v=0;v<count;v++)
{
var p=mesh.Vertices[v];var weights=new float[bones.Length];
bool reached=graph.Geodesic.Any(g=>float.IsFinite(g[v]));
for(int b=0;b<bones.Length;b++)
{
if(!bones[b].Deform) continue;
var role=bones[b].Role;
// Side constraints select the geodesic seeds above. Do not cut a smooth
// solved field again at an X plane: that creates a visible skin tear.
var distance=reached ? graph.Geodesic[b][v] : graph.Distances[v][b];
// Surface routes can overestimate distance to a deeply buried
// local bone. Retain connectivity/visibility as the constraint,
// then use physical distance as an independent locality prior.
// An unreachable bone remains unreachable; this cannot bridge
// a gap between disconnected fingers or nearby limbs.
if(useLocalityPrior&&reached)
distance=.5f*distance+.5f*Vector3.Distance(p,Geometry.ClosestOnSegment(p,bones[b].Position,ends[b]));
// The nearest distance carries the exterior penalty; a surface route
// can be shorter. A bone reached that easily is simply fully present,
// never infinitely so.
weights[b]=MathF.Exp(-Math.Max(0,distance-graph.Nearest[v])/Math.Max(height*.03f,.001f));
}
// Apply the measured limb boundaries here, so later repairs start
// from anatomy rather than having to restore it.
cache.Trunk!.Constrain(p,cache.TrunkVertices![v],claim);
for(int b=0;b<bones.Length;b++)weights[b]*=claim[b];
// The field never quite reaches zero. On a very large torso its tail
// leaves a neck weight on the belly. Fade each bone out toward its
// reach, as the heat solve does. A tall accessory can lie beyond
// every bone's reach; it keeps the field it had.
float faded=0;
for(int b=0;b<bones.Length;b++)
{
fade[b]=0;if(weights[b]<=0)continue;
float t=Math.Clamp((Vector3.Distance(p,Geometry.ClosestOnSegment(p,bones[b].Position,ends[b]))/cache.Locality!.Limit(b,p)-.72f)/.24f,0,1);
fade[b]=weights[b]*(1-t*t*(3-2*t));faded+=fade[b];
}
if(faded>=1e-30f)Array.Copy(fade,weights,weights.Length);
var total=weights.Sum();
if(total<1e-30f) throw new InvalidOperationException($"Mesh '{mesh.Name}' is too far from the body to skin safely.");
for(int b=0;b<bones.Length;b++) weights[b]/=total;
cache.Trunk.Cap(p,cache.TrunkVertices[v],weights,bones,ends);
seeds[v]=weights;field[v]=(float[])weights.Clone();
}
var nextField=Enumerable.Range(0,count).Select(_=>new float[bones.Length]).ToArray();
int workers=RigWork.WorkerCount(count);
// Each pass keeps a share of the seed, so the blend reaches about
// 1/(1-share) edges however many passes run. On a sculpt whose
// edges are a millimetre that is no distance at all, and the pores
// and wrinkles the distances see fold every tiny face; the blend
// then reaches a hundredth of the height instead. Coarser meshes
// keep the two-edge blend they always had.
double edgeTotal=0;long edgeCount=0;
foreach(var row in graph.Edges)foreach(var edge in row){edgeTotal+=edge.Length;edgeCount++;}
float hops=edgeCount==0?0:(float)(height*.01/(edgeTotal/edgeCount));
float neighborShare=Math.Max(.55f,1-1/Math.Max(hops,1)),seedShare=1-neighborShare;
int iterations=Math.Max(12,(int)MathF.Ceiling(hops*4));
void Diffuse(int worker)
{
for(int v=worker*count/workers;v<(worker+1)*count/workers;v++)
{
for(int b=0;b<bones.Length;b++)
{
float sum=0,denominator=graph.Denominators[v];
foreach(var edge in graph.Edges[v])sum+=field[edge.Node][b]*edge.Conductance;
nextField[v][b]=seedShare*seeds[v][b]+neighborShare*(denominator>0 ? sum/denominator : field[v][b]);
}
}
}
for(int iteration=0;iteration<iterations;iteration++)
{
// Fixed vertex ranges share read-only input, then join before
// swapping fields. Each vertex keeps its original summation order.
RigWork.For(workers,workers,Diffuse);
(field,nextField)=(nextField,field);
}
result[part]=field.Select(w=>Cleanup(Limit(w,rig.Profile.MaximumInfluences),rig.Profile.MaximumInfluences)).ToArray();
}
return (cache.Hugging??=new SurfaceHugging(character,rig)).Apply(result,bones.Length,rig.Profile.MaximumInfluences);
}
/// <summary>Dropping the weakest influence outright leaves a step in the
/// field. Across the short edges of a dense mesh even a few percent is a
/// severe stretch once a limb moves. The last influence kept gives way to
/// the first omitted instead, reaching zero as that one rises to meet it,
/// while every other kept weight stands. Lowering every weight by the
/// omitted one, as this once did, left a pectoral with six near-equal
/// influences only their differences: a smooth field became jumps of a
/// tenth between neighbouring vertices once renormalized.</summary>
internal static float[] Limit(float[] weights,int maximum)
{
if(maximum<=0||weights.Length<=maximum)return weights;
Span<float> strongest=stackalloc float[maximum+1];int count=0;
foreach(float value in weights)
{
if(!float.IsFinite(value)||value<=0)continue;
int at=count;while(at>0&&value>strongest[at-1])at--;
if(at>maximum)continue;
for(int i=Math.Min(count,maximum);i>at;i--)strongest[i]=strongest[i-1];
strongest[at]=value;count=Math.Min(count+1,maximum+1);
}
if(count<=maximum||strongest[0]<=strongest[maximum])return weights;
float omitted=strongest[maximum],last=strongest[maximum-1];bool lowered=false;
for(int b=0;b<weights.Length;b++)
{
if(!float.IsFinite(weights[b])||weights[b]<=omitted)weights[b]=0;
else if(!lowered&&weights[b]==last){weights[b]=last-omitted;lowered=true;}
}
return weights;
}
public static Influence[] Cleanup(IEnumerable<Influence> source,int boneCount,int maximum)
{
var ranked=source.Where(i=>i.Bone>=0 && i.Bone<boneCount && float.IsFinite(i.Weight) && i.Weight>0)
.GroupBy(i=>i.Bone).Select(g=>new Influence(g.Key,g.Sum(i=>i.Weight))).OrderByDescending(i=>i.Weight).ToArray();
return Normalize(ranked.Take(maximum).ToArray());
}
/// <summary>Most cleanup calls revisit four or eight existing influences.
/// Keep their stable grouping and ranking without constructing a LINQ lookup.</summary>
public static Influence[] Cleanup(Influence[] source,int boneCount,int maximum)
{
if(source.Length>32)return Cleanup((IEnumerable<Influence>)source,boneCount,maximum);
if(maximum<=0||source.Length==0)return [];
Span<int> bones=stackalloc int[source.Length];Span<double> sums=stackalloc double[source.Length];int count=0;
foreach(var influence in source)
{
if(influence.Bone<0||influence.Bone>=boneCount||!float.IsFinite(influence.Weight)||influence.Weight<=0)continue;
int at=0;while(at<count&&bones[at]!=influence.Bone)at++;
if(at==count){bones[count]=influence.Bone;sums[count]=0;count++;}
sums[at]+=influence.Weight;
}
Span<Influence> ranked=stackalloc Influence[count];
for(int i=0;i<count;i++)
{
var value=new Influence(bones[i],(float)sums[i]);int at=i;
while(at>0&&value.Weight>ranked[at-1].Weight){ranked[at]=ranked[at-1];at--;}
ranked[at]=value;
}
return Normalize(ranked[..Math.Min(count,maximum)].ToArray());
}
/// <summary>Dense solver fields already have one slot per bone. Select their
/// strongest entries without allocating/grouping every zero and weak slot.</summary>
internal static Influence[] Cleanup(float[] source,int maximum)
{
if(maximum<=0)return [];
int limit=Math.Min(maximum,source.Length),count=0;
var bones=new int[limit];var values=new float[limit];
for(int bone=0;bone<source.Length;bone++)
{
float value=source[bone];if(!float.IsFinite(value)||value<=0)continue;
int at=count;while(at>0&&value>values[at-1])at--;
if(at>=limit)continue;
int end=Math.Min(count,limit-1);
for(int i=end;i>at;i--){bones[i]=bones[i-1];values[i]=values[i-1];}
bones[at]=bone;values[at]=value;count=Math.Min(count+1,limit);
}
if(count==0)return [];
var valid=Enumerable.Range(0,count).Select(i=>new Influence(bones[i],values[i])).ToArray();
return Normalize(valid);
}
static Influence[] Normalize(Influence[] valid)
{
if(valid.Length==0)return [];
float sum=valid.Sum(i=>i.Weight);
double retained=0;int count=0;
foreach(var influence in valid)if(influence.Weight/sum>=.001f){retained+=influence.Weight;count++;}
var result=new Influence[count];int index=0;float divisor=(float)retained;
foreach(var influence in valid)if(influence.Weight/sum>=.001f)result[index++]=influence with{Weight=influence.Weight/divisor};
return result;
}
}