Categories
Game Dev

Automated 2D Animation Creation

If you’re familiar at all with Unity animations and the animation controller, you already know the entire workflow can be very repetitive for each new asset you add to your project. The solution I have is specifically for 2D, but I’m sure this can be done similarly for 3D as well. Some games may require a large amount of very similar sprites, the process of splitting a spritesheet and creating animations for every single new sprite would get annoying very quickly. I figured there had to be a way to automate splitting the spritesheet into individual sprites, and I already knew how to create unity asset files in editor scripts, so I was sure there had to be a way to do that as well.

The first step is creating a custom AssetPostprocessor. This is going to be an editor script in Unity that runs every time a new asset is added. This definitely looks like the right place to do everything based on the naming. I use two functions here, OnPreprocessTexture and OnPostprocessTexture. During the preprocessing step, the texture importer is configured for default parameters that are needed for all sprites. In the postprocessing step, I then take the spritesheet and split it up into individual sprite assets.

void OnPreprocessTexture()
{
  TextureImporter importer = (TextureImporter)assetImporter;
  // these are always sprites
  importer.textureType = TextureImporterType.Sprite;
  // 16 pixels per tile
  importer.spritePixelsPerUnit = 16.0f;
  // no reason to have mips with such small assets
  importer.mipmapEnabled = false;
  // always sprite sheets and tilemaps
  importer.spriteImportMode = SpriteImportMode.Multiple;

  // clamp to last texel when uv-coords wrap
  importer.wrapMode = TextureWrapMode.Clamp;
  // point sampling, no anti-aliasing/bluring
  importer.filterMode = FilterMode.Point;
  // uncompressed texture to prevent potential blurring
  importer.textureCompression = TextureImporterCompression.Uncompressed;
}

These are all very basic settings that will be applied to every texture added. Additional filtering is done based on where the asset is located, but for spritesheets, these are the settings that I personally have found work best.

void OnPostprocessTexture(Texture2D texture)
{
  TextureImporter importer = (TextureImporter)assetImporter;

  int columnWidth = texture.width / NumColumns;
  int rowHeight = texture.height / NumRows;

  // get the asset name out of the full path
  string characterBase = assetPath.Substring(assetPath.LastIndexOf('/') + 1);

  // define sprite rect sizes based on texture dimensions
  SpriteMetaData[] spriteData = new SpriteMetaData[NumColumns * NumRows];
  for (int i = 0; i < NumColumns * NumRows; ++i)
  {
    Rect r = new Rect();
    r.width = columnWidth;
    r.height = rowHeight;
    r.x = (i % NumColumns) * columnWidth;
    r.y = (i / NumColumns) * rowHeight;

    SpriteMetaData meta = new SpriteMetaData();
    meta.rect = r;
    // put a more descriptive name here if it would be useful
    meta.name = "Frame_" + i.ToString();
    spriteData[i] = meta;
  }

  // tell the importer what the spritesheet should look like
  importer.spritesheet = spriteData;
  AssetDatabase.SaveAssets();
  AssetDatabase.Refresh();
}

This step is all fairly straightforward as long as you know all the classes to use. Each SpriteMetaData defines the bounds of an individual sprite within the overall texture. NumRows and NumColumns are defined elsewhere, but since I’m just talking about a predefined format for spritesheets in this project, these numbers can change for whatever you are working on.

With just this code alone you would see for any texture dropped into your project, it would automatically be split into individual sprites based on the grid sizes you define. At this point the only thing left to do is create the animations, and if you were doing research before you’d fine that there is a OnPostprocessSprites function that gives you all the sprites created after OnPostprocessTexture, seems like a good place to put this! Unfortunately, it won’t work. Attempting to do animation processing here will result in your animations containing null sprite references. The Sprite[] passed in to OnPostprocessSprites does not contain the actual sprite assets, but instances, and if you try to use AssetDatabase to load the assets themselves, it will fail at this point in the pipeline as they don’t actually exist yet. What I ended up doing is creating a new editor window that does everything.

I don’t want to go into too much detail on creating new editor windows at this time (maybe another day), but there are a few pieces that are crucial, and I found difficult to find answers about online. The first part, since this is a separate tool, we need a way to pick the texture we want to use for our animations. Unity has an asset picker already, as such we can use that with a little bit of work.

if (GUILayout.Button("Select Texture"))
{
  string searchFilter = "-Character-";
  int controlID = GUIUtility.GetControlID(FocusType.Passive);
  EditorGUIUtility.ShowObjectPicker<Texture2D>(null, false, searchFilter, controlID);
}

if (Event.current.commandName == "ObjectSelectorUpdated")
{
  Object objectPicker = EditorGUIUtility.GetObjectPickerObject();
  string assetPath = AssetDatabase.GetAssetPath(objectPicker);
  _loadedTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
}

The object picker has a couple nuances that are not necessarily clear based on common search results. The first part where it’s brought up is fairly straightforward, searchFilter is the most important part as it provides an easy way to automatically filter down to only the textures you care about. The controlID is a mystery to me, effectively it appears to be a handle that the object picker uses to send messages back to your custom editor window. The second part is how do you get the chosen asset back from the object picker? The answer to this is the Event. ObjectPicker sends an event named “ObjectSelectorUpdated” when an asset has been chosen, this event has to be checked explicitly inside OnGUI. Once you have the event, you can get the selected asset from the object picker, from which you can then load the asset and start doing processing.

string assetPath = AssetDatabase.GetAssetPath(_loadedTexture);

// load all sprites from the texture and sort them based on 
// names set in the importer
Sprite[] sprites = AssetDatabase.LoadAllAssetsAtPath(assetPath).OfType<Sprite>().ToArray();
Array.Sort(sprites, (x, y) => StringComparer.Ordinal.Compare(x.name, y.name));

// get the base name of the asset, in this example, the character name
string animationAssetBase = assetPath.Substring(0, assetPath.IndexOf('-'));

// this will be a running list of paths for the animation clips
// it will be used later
List<string> animationClipPaths = new List<string>();

// assuming each row is a unique animation
for (int i = 0; i < SpriteAssetImporter.NumRows; ++i)
{
  string animName = string.Format("{0}-WalkCycle-{1}",
    animationAssetBase, 
    WalkNamesSorted[i] // this would be something like a direction
  );

  // clip is the asset that will be saved in the end
  AnimationClip clip = new AnimationClip();
  clip.frameRate = 12.0f;
  clip.name = animName;

  // this contains various settings, we only care about 
  // enabling animation looping
  AnimationClipSettings clipSettings = new AnimationClipSettings();
  clipSettings.loopTime = true;

  // the curve contains individual animation frames
  EditorCurveBinding animationCurve = new EditorCurveBinding();
  animationCurve.type = typeof(SpriteRenderer);
  animationCurve.path = "";
  animationCurve.propertyName = "m_Sprite";

  // get the individual keyframes from the sprite list
  ObjectReferenceKeyframe[] keyFrames = new ObjectReferenceKeyframe[SpriteAssetImporter.NumColumns];
  for (int j = 0; j < keyFrames.Length; ++j)
  {
    keyFrames[j] = new ObjectReferenceKeyframe();
    keyFrames[j].time = j / clip.frameRate;
    // the math here depends entirely on your sprite names
    keyFrames[j].value = sprites[i * SpriteAssetImporter.NumColumns + j];
  }

  // apply settings and keyframes to the clip
  AnimationUtility.SetAnimationClipSettings(clip, clipSettings);
  AnimationUtility.SetObjectReferenceCurve(clip, animationCurve, keyFrames);

  // create the animation clip asset
  string clipName = string.Format("{0}.anim", animName);
  AssetDatabase.CreateAsset(clip, clipName);
  animationClipPaths.Add(clipName);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();

There’s a lot to parse here, but at a high level I’ll go through this in steps. First step is loading all of the individual sprites from our selected texture. Second is creating the clip asset that we will eventually save, name and framerate depend entirely on your project. Third step is creating a special settings object, the only reason this is done is so we can tell our animation that it should loop, why this isn’t part of the clip itself, I’ll never understand. The fourth step is finding all the keyframes for this animation and adding them to a list. The final step is applying all these settings and saving the asset off.

If you were to stop here you’d notice that you have a handful of animation clips now saved in the same directory as your source texture. However I had one more thing I wanted … and that was to create the animation controller on top of that. Unity provides a very powerful tool in the animation controller that allows you to create derived controllers. In a derived controller, the only thing you see is a list of original animations, and the ability to replace them with new clips. The benefit here is that you would only ever need to create your animation controller once.

Example animation controller for overhead 8-directional walking.
What an override controller looks like, replacing old animations with new.

You may look at the override controller and say it’s not that much work to just swap out with new animations, but what if you didn’t have to do that and automated the creation of this yourself. It’s not actually much more code to do, and saves an additional minute every time a new spritesheet is imported.

// get the base animation controller
string baseControllerPath = Path.Combine("Assets", "PathToController", "AnimControllerBase.controller");
RuntimeAnimatorController baseController = AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(baseControllerPath);

// create an override controller based on the original
AnimatorOverrideController animationController = new AnimatorOverrideController(baseController);

// create animation controller
List<KeyValuePair<AnimationClip, AnimationClip>> overridePairs = new List<KeyValuePair<AnimationClip, AnimationClip>>();

for (int i = 0; i < animationClipPaths.Count; ++i)
{
  // get original animation to override
  AnimationClip original = animationController.animationClips[i];

  // attempt to figure out what animation it is
  string targetAnimation = original.name.Substring(original.name.IndexOf('-')) + ".anim";
                
  // find the new animation based on name extracted from the old
  string newAnimationPath = animationClipPaths.Find((str)=>str.Contains(targetAnimation));

  // load the override animation
  AnimationClip animOverride = AssetDatabase.LoadAssetAtPath<AnimationClip>(newAnimationPath);

  // set the old/new as a pair where old is the key
  overridePairs.Add(new KeyValuePair<AnimationClip, AnimationClip>(original, animOverride));
}

// apply override animations
animationController.ApplyOverrides(overridePairs);

// save the animation controller last
string animationControllerName = string.Format("{0}AnimOverworldController.controller", animationAssetBase);
AssetDatabase.CreateAsset(animationController, animationControllerName);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();

There’s not a lot of code here and each step is explained pretty clearly. Effectively what happens is you need a way to figure out from the old animation, what the new animation should be. I do this with a uniform naming scheme, so each character has a set of animations with “almost” the same name. The animation clips are stored as a key/value pair and passed to ApplyOverrides before saving the asset.

After this extra little bit of code, you’ll end up with animations and the animation controller off of just a texture. For each individual spritesheet this can easily save up to 15-20 minutes over even the most optimal workflows of the Unity default asset creation. If your project ends up with 10, 20, or even more, the time saved from a couple hundred lines of code can add up to full work days worth of very repetitive time.