I think I've got it!
Just for the sake of completeness, here's where I landed. We have a class that gets instantiated per Spine model. Every player, monster, NPC, etc. has one of these, and it has the SetSkins
method. We also store a number of other relevant Spine-related information there, like the model's game object, its SkeletonAnimation instance, etc. However, since it's a one-to-one mapping between each model and itself, it meant that when I was calling skeleton.Data.DefaultSkin.Attachments.Clear()
, I was doing that once per model, not once for each shared SkeletonData instance (since there may be a number of players on the screen sharing a single SkeletonData instance). That meant that the first player to spawn looked great, but every subsequent player (or any other NPCs that shared the same SkeletonData instance) were using the previously cleared skin as their default, causing problems.
I've changed that logic to use a static dictionary now
one per SkeletonData instance:
// Class field
private static readonly Dictionary<string, Skin> defaultRigSkins = new Dictionary<string, Skin>();
// In the constructor
if(!defaultRigSkins.ContainsKey(rigDefinitionId))
{
defaultRigSkins.Add(rigDefinitionId, this.animationControl.Skeleton.Data.DefaultSkin.GetClone());
this.animationControl.Skeleton.Data.DefaultSkin.Attachments.Clear();
}
Then, in SetSkins
for that same class, things have been simplified even more:
-
Create a new "composite" skin
-
Grab the default skin for the given SkeletonData instance
-
Copy the default skin into the new skin
-
Iterate over all the skins to use in the new composite skin, and add their attachments to the composite skin
-
If the skin needs to be repacked, call GetRepackedSkin
-
Set the skeleton's skin, set the slots to the setup pose, and apply the animation state
Here's the code:
public void SetSkins(List<string> skinIds)
{
if(skinIds.Count == 0) return;
Skeleton skeleton = this.animationControl.skeleton;
Skin newUnsharedSkin = defaultRigSkins[this.rigDefinitionId];
Skin newSkin = new Skin("composite");
newUnsharedSkin.CopyTo(newSkin, true, false);
for(int i = 0; i < skinIds.Count; i++)
{
Skin itemSkin = skeleton.Data.FindSkin(skinIds[i]);
if(itemSkin != null)
{
newSkin.AppendSkin(itemSkin);
}
}
if(ShouldRepackSkin())
{
UnityEngine.Material runtimeMaterial;
Texture2D runtimeAtlas;
newSkin = newSkin.GetRepackedSkin("repacked", rigShader, out runtimeMaterial, out runtimeAtlas, 2048);
if(this.characterView != null)
{
if(this.characterView.RuntimeMaterial != null)
{
this.characterView.RuntimeMaterial.mainTexture = null;
UnityEngine.Object.Destroy(this.characterView.RuntimeTexture);
}
this.characterView.RuntimeMaterial = runtimeMaterial;
this.characterView.RuntimeTexture = runtimeAtlas;
}
}
skeleton.SetSkin(newSkin);
skeleton.SetSlotsToSetupPose();
this.animationControl.AnimationState.Apply(skeleton);
}
The one thing that caught me up was that my implementation for ShouldRepackSkin
was coming back false for characters that had 30+ textures and should have absolutely been repacked. What ended up happening was that my initial check in that method was against the MeshRenderer's sharedMaterials
property. After the process of compositing and repacking the first skin for a given SkeletonData instance, the number of shared materials for subsequent characters of the same instance became 0 (since I assume they were no longer shared, but were now unique to each model). I'm now checking that it's not equal to 1, which works for new and subsequent characters alike:
private bool ShouldRepackSkin()
{
// This could be 0 if we've already repacked the skin and set it as an unshared (i.e., unique to the character) material
bool shouldRepack = this.rigGO.GetComponent<MeshRenderer>().sharedMaterials.Length != 1;
shouldRepack = shouldRepack || this.characterView != null && this.characterView.RuntimeMaterial != null;
return shouldRepack;
}
(I should probably cache that GetComponent
call.)
Now, I've got 1 draw call per repacked character, no observable memory leaks (although I did have to add a call to Resources.UnloadUnusedAssets
in the OnDestroy
method of a MonoBehaviour attached to each skeleton, since when we stop using a MeshRenderer's sharedMaterials
property and instead use materials
unique to that instance, Unity doesn't automatically clean it up for us), and no stale attachments after re-skinning characters. Such a deal!
As ever, thanks for all your help on this. We're still seeing a few things we need to sort out after the upgrade from Spine 2 to 3 (sometimes our death animation doesn't play and models just pop out of existence, likely something to do with changes to TrackEntry callbacks), but we're getting there, and this was a huge step along the way.