Is it possible to turn an imported picture into a sprite and separate Animation Clip?

For my current project, I’m forced to repeat a task that tends to be quite time consuming: importing sprites and turning them into animations. This is a 2D project with all animation based on Spritesheets.

To describe the manual process:

  1. Drop the full sprite sheet into the assets
  2. Change “Sprite Mode” to “Multiple”
  3. Go into the “Sprite Editor” and break up the sprite sheet into frames
  4. Drag the result into the Scene to create an Animation Clip
  5. Edit the Animation Clip, adjusting the frame count hold for every frame

1-4 doesn’t take much effort, but it’s point 5 that can waste a lot of time - especially with very long animations. I have to manually move around frames and count them, matching them to the expected frame holds.

I’ve tried to experiment around with custom ScriptedImporter stuff, but I suspect I can’t achieve what I want with it. Is it still true that even a custom ScriptedImporter can only generate one asset? Since in my case, 1 picture would be turned into a Texture2D/Sprite and a separate Animation Clip, I wouldn’t be able to solve my problem then.

Is there another way how I could achieve this? Maybe add a custom button in the Editor to “generate Animation Clip” out of a Texture2D/Sprite? Or even a button directly on the Animation Clip, where it distributes the frames automatically for me (perhaps by inputting a string of frame holds) would already be a massive help.

Any custom solution that helps me avoid having to re-import and re-distribute Animation Clips would probably save me hours of work - this is easily the most time consuming task of the whole project :slight_smile:

Any ideas or suggestions? How do you guys solve this kind of thing?

I think there is a bit of confusion here.
ScriptedImporters can only import one file, and you have to choose which importer is going to target this file, but they can generate as many assets as you want.
The issue you may have here is that you don’t want to re-write the full TextureImporter into your own ScriptedImporter just to generate animations on top of it.

The easiest way, if you’re using Unity 2022.2 and above, is to use an asset postprocessor:
Use https://docs.unity3d.com/ScriptReference/AssetPostprocessor.OnPostprocessSprites.html to generate your animation clip and add it to the import context. It will appear as a sub-asset along with the sprites and you’re good to go for every sprite you set up in your project.

On top of that, if all your images have the same size and sprites split, you can create a Preset from the inspector and add it in your default in the PresetManager so that any new texture you’re adding in your sprite folder will get the appropriate settings, saving you from yours steps 2 and 3.

If you’re on an earlier version of Unity, AddObjectToAsset is not working from AssetPostprocessors unfortunately.
What you could do though, is to create a ScriptedImporter that would target an empty file (with your own extension, like .spriteClip), in which you can reference the sprites from which you want to create your animation.
It would look something like:

[ScriptedImporter(1, ".spriteClip")]
public class SpriteClipImporter : ScriptedImporter
{
    public Texture2D texture;
   
    public override void OnImportAsset(AssetImportContext ctx)
    {
        AnimationClip clip = new AnimationClip();
        // This is to make sure you animation clip is re-imported if the sprites changes
        ctx.DependsOnSourceAsset(texture);
        // load all of the sprites
        var sprites = AssetDatabase.LoadAllAssetsAtPath(AssetDatabase.GetAssetPath(texture));
        // TODO: link the sprites in the clip and set the times correctly
        // add the completed clip to the import result
        ctx.AddObjectToAsset("clip", clip);
    }
}

At that point, you just have to create an empty file with the right extension for each animationClip you want to create and link the main texture of the sprites that need to be part of that animationClip.
Doing it this also allows you to keep the actual TextureImporter as-is without rewriting a texture import and sprite splitter yourself.

Thank you very much, I’ll give it a try when I’m off work tomorrow!

The “one asset per import” thing was just something I stumbled over while looking into the whole process, where I read that the importer can only create one asset out of one imported file to maintain some source-to-result relationship.

And yeah, as you yourself already mentioned - if possible, I don’t want to throw away what the TextureImporter already does, since it’s pretty nice as it is. I’ll give the PostProcess a try and report back! :slight_smile:

@bastien_humeau Alright, took me longer than I thought, but I almost managed to get things going… but I keep running into the problem, that any animation I create with the given sprites / textures disappear.

Below is my code so far, put together from several different sources. It does everything I need: it cuts the Texture into appropriate sprites, creates an animation with the correct holds between keyframes - everything works… but the animation is always empty (all frames are completely transparent).

Stuff that doesn’t work is in OnPostprocessSprites(), I marked it where I’m using sprites*;*
(to test the code, just create a folder named “spritetest” inside the assets, or just change PathContains)
```csharp
*public class SpritePostprocessor : AssetPostprocessor {

// Example file name: "Player_Walk_200x100_h3-3-2-3-3-4-5"

protected const string PathContains   = "spritetest";
protected const char   NameSeparator  = '_';
protected const char   SizeSeparator  = 'x';
protected const char   HoldsSeparator = '-';

protected static string _processedFile;

void OnPreprocessTexture() {
    if( !this.assetPath.Contains( SpritePostprocessor.PathContains ) ) return;

    Debug.Log( "OnPreprocessTexture(): Processing File: " + this.assetPath );

    TextureImporter importer = (TextureImporter)this.assetImporter;
    importer.spriteImportMode = SpriteImportMode.Multiple;
}

void OnPostprocessTexture( Texture2D texture ) {
    if( !this.assetPath.Contains( SpritePostprocessor.PathContains ) ) return;

    Debug.Log( "OnPostprocessTexture(): Processing File " + this.assetPath );

    // Used to ignore the re-import
    if( SpritePostprocessor._processedFile != null ) {
        SpritePostprocessor._processedFile = null;

        return;
    }

    SpritePostprocessor._processedFile = this.assetPath;
   
    SpritesheetNameParser parser = new SpritesheetNameParser( texture );

    if( !parser.isValid ) {
        Debug.Log( "OnPostProcessSprites: Unknown file name pattern: " + texture.name );

        return;
    }


    // cut spritesheet into pieces

    TextureImporter importer = (TextureImporter)this.assetImporter;

    if( importer.spriteImportMode != SpriteImportMode.Multiple ) return;

    Rect[] rects = InternalSpriteUtility.GenerateGridSpriteRectangles(
        texture,
        Vector2.zero,
        parser.bounds,
        Vector2.zero,
        false
    );

    int index = 0;

    importer.spritesheet = rects.Select( ( rect ) => new SpriteMetaData {
        pivot     = Vector2.down,
        alignment = (int)SpriteAlignment.BottomCenter,
        rect      = rect,
        name      = parser.name + '_' + index++
    } ).ToArray();

    // Not sure why this is necessary... but its necessary.
    AssetDatabase.ForceReserializeAssets( new List<string>{ this.assetPath } );
    AssetDatabase.ImportAsset( this.assetPath, ImportAssetOptions.ForceUpdate );
}

// THIS IS WHERE STUFF STOPS WORKING
// "sprites" and "texture" seem to be temporary instances. If I use them, they are removed afterwards by Unity.
// but I can't get a hold of the "real" images, any attempt to get something through AssetDatabase returns "null"
protected void OnPostprocessSprites( Texture2D texture, Sprite[] sprites ) {
    if( !this.assetPath.Contains( SpritePostprocessor.PathContains ) ) return;
   
    if( sprites.Length == 0 ) {
        AssetDatabase.ImportAsset( this.assetImporter.assetPath );

        return;
    }

    // AssetDatabase.SaveAssets(); doesn't work
    // AssetDatabase.Refresh(); doesn't work

    SpritesheetNameParser parser = new SpritesheetNameParser( texture );

    int frameRate = 24;

    AnimationClip clip = new AnimationClip();
    clip.frameRate = frameRate;

    EditorCurveBinding spriteBinding = new EditorCurveBinding();
    spriteBinding.type = typeof( SpriteRenderer );
    spriteBinding.path = "";
    spriteBinding.propertyName = "m_Sprite";

    int time = 0;

    ObjectReferenceKeyframe[] spriteKeyFrames = new ObjectReferenceKeyframe[ sprites.Length ];

    for( int i = 0; i < sprites.Length; i++ ) {
        spriteKeyFrames[ i ]       = new ObjectReferenceKeyframe();
        spriteKeyFrames[ i ].time  = (float)time / (float)frameRate;
        spriteKeyFrames[ i ].value = sprites[ i ]; // The frames cease to exist after this function call

        time += parser.holds[ i ];
    }

    AnimationUtility.SetObjectReferenceCurve( clip, spriteBinding, spriteKeyFrames );

    AssetDatabase.CreateAsset( clip, "Assets/Assets/spritetest/" + parser.name + ".anim" );
    AssetDatabase.SaveAssets();
    AssetDatabase.Refresh();
}

/**
 * Utility class to parse components of a specific file name.
 */
public class SpritesheetNameParser {

    public string originalName { get; protected set; }
    public bool isValid { get; protected set; } = true;

    public string name { get; protected set; }
    public Vector2 bounds { get; protected set; }
    public int[] holds { get; protected set; }

    public SpritesheetNameParser( Texture2D texture ) {
        this.originalName = texture.name;

        this.ParseName();
    }

    public SpritesheetNameParser( string name ) {
        this.originalName = name;

        this.ParseName();
    }

    protected void ParseName() {
        Regex regex   = new Regex( "(?<name>.*?)_(?<width>\\d+?)x(?<height>\\d+?)_h(?<holds>.*?)$" );
        Match matches = regex.Match( this.originalName );

        if( !matches.Success ) {
            this.isValid = false;

            return;
        }

        try {
            this.name = matches.Result( "${name}" );
       
            int width  = Int32.Parse( matches.Result( "${width}" ) );
            int height = Int32.Parse( matches.Result( "${height}" ) );

            this.bounds = new Vector2( width, height );

            string[] holds  = matches.Result( "${holds}" ).Split( SpritePostprocessor.HoldsSeparator );

            this.holds = holds.Select( int.Parse ).ToArray();
        } catch( FormatException exception ) {
            Debug.Log( $"Unable to parse '${name}'" );
            Debug.Log( exception.Message );

            return;
        }
    }

}

}*
```
It’s the same problem that was mentioned and showed in the comments here: Create Animation Clip from Sprites[] (Programmatically) - Questions & Answers - Unity Discussions but the linked solution doesn’t work for me.
This is how my animation looks as well, after trying the above (the frames are just empty):

I tried all possible combinations of AssetDatabase.LoadAllAssetRepresentationsAtPath(), AssetDatabase.LoadAssetAtPath() etc - nothing seems to work. How can I get a hold of the real sprites to assign them to the animation? Am I trying to do this in the wrong spot?
I can’t find any solutions to this online, I really hope someone can give me that last puzzle piece to finish this up - it took longer to write this part, than writing the entire game :slight_smile:

I’m facing the same problem , and also get stuck at final step like you , if you could resolve this , please inform me

Hey guys , I finally resolve this problem . The flow is you have seperate these steps :
1/ Write a postprocess script to auto slice single sprite into many when import , save those assets by assetdatabase
2/ After saving above assets , load sprites into animations on Unity Editor , you could use my scripts bellow

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;

#if UNITY_EDITOR
public class PostProcessImportTexutre : AssetPostprocessor
{
    //Custom your wished size here
    public static readonly Vector2 SPRITE_SIZE = Vector2.one * 105f;

    [MenuItem("Tool/PostImportTexture/Enable")]
    public static void EnableAnimGenerator()
    {
        EnableImageToAnimImport = true;
        Debug.Log($"ENABLE import to anim successfully, content path check = {PathContains}");
    }

    [MenuItem("Tool/PostImportTexture/Disable")]
    public static void DisableAnimGenerator()
    {
        EnableImageToAnimImport = false;
        Debug.Log($"DISABLE import to anim successfully, content path check = {PathContains}");
    }

    //Sprite path to check ,custom here
    protected const string PathContains = "NFT_CHAR";
   
    protected static string _processedFile;
    protected static bool EnableImageToAnimImport = false;

    void OnPreprocessTexture()
    {
        if (!EnableImageToAnimImport) return;
        if (!this.assetPath.Contains(PathContains)) return;

        Debug.Log("OnPreprocessTexture(): Processing File: " + this.assetPath);

        TextureImporter importer = (TextureImporter)this.assetImporter;
        importer.spriteImportMode = SpriteImportMode.Multiple;
    }

    void OnPostprocessTexture(Texture2D texture)
    {
        if (!EnableImageToAnimImport) return;
        if (!this.assetPath.Contains(PathContains)) return;

        Debug.Log("OnPostprocessTexture(): Processing File " + this.assetPath);

        // Used to ignore the re-import
        if (_processedFile != null)
        {
            _processedFile = null;

            return;
        }

        _processedFile = this.assetPath;

        // cut spritesheet into pieces

        TextureImporter importer = (TextureImporter)this.assetImporter;

        if (importer.spriteImportMode != SpriteImportMode.Multiple) return;

        Rect[] rects = InternalSpriteUtility.GenerateGridSpriteRectangles(
            texture,
            Vector2.zero,
            SPRITE_SIZE,
            Vector2.zero,
            false
        );

        Debug.Log("rect width,height :" + rects[0].width + "," + rects[0].height);

        int index = 0;

        importer.spritesheet = rects.Select(rect => new SpriteMetaData
        {
            pivot = Vector2.down,
            alignment = (int)SpriteAlignment.BottomCenter,
            rect = rect,
            name = texture.name + "_" + ++index
        }).ToArray();

        AssetDatabase.ForceReserializeAssets(new List<string> { this.assetPath });
    }
}

#endif
using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using Color = UnityEngine.Color;

#if UNITY_EDITOR
public class ImageToAnimGenerator
{
    //Custom path here
    public static string NFTAssetPath = Path.Combine(Utilities.DataPath, "InHouse", "Art", "NFT_Char");

    [MenuItem("Tool/LoadImageToAnim")]
    public static void LoadImageToAnim()
    {
        try
        {
            var pngFilePaths = Directory.GetFiles(NFTAssetPath, "*.png", SearchOption.AllDirectories);
            var fLength = pngFilePaths.Length;
            Debug.Log($"There're {fLength} images");
            int curIndex = 0;

            foreach (var pngPath in pngFilePaths)
            {
                ++curIndex;
                var progress = (float)curIndex / fLength;
                EditorUtility.DisplayCancelableProgressBar("Loading png to texture", "", progress);

                var relativePath = pngPath.FullPathToUnityRelativePath();
                GenerateAnim(relativePath);
            }
        }
        catch (Exception e)
        {
            Debug.LogError(e.Message);
        }
        finally
        {
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();
            EditorUtility.ClearProgressBar();
        }
    }

    public static void GenerateAnim(string relativeAssetPath)
    {
        var sprites = AssetDatabase.LoadAllAssetRepresentationsAtPath(relativeAssetPath).Select(x => (Sprite)x)
            .ToList();

        sprites.ForEach(x => Debug.Log(x.name));

        Debug.Log($"{sprites.Count} sprite at {relativeAssetPath}");
        AnimationClip clip = new AnimationClip { frameRate = TOOLANIMCONFIG.ANIM_FRAME_RATE };

        EditorCurveBinding spriteBinding = new EditorCurveBinding
        {
            type = typeof(SpriteRenderer), path = "", propertyName = "m_Sprite"
        };

        ObjectReferenceKeyframe[] spriteKeyFrames = new ObjectReferenceKeyframe[sprites.Count];

        for (int i = 0; i < sprites.Count; i++)
        {
            spriteKeyFrames[i] = new ObjectReferenceKeyframe
            {
                time = i * TOOLANIMCONFIG.FRAME_TIME,
                value = sprites[i] // The frames cease to exist after this function call
            };
        }

        AnimationUtility.SetObjectReferenceCurve(clip, spriteBinding, spriteKeyFrames);

        var animPath = GetAnimSavePath(relativeAssetPath);
        AnimationUtility.SetObjectReferenceCurve(clip, spriteBinding, spriteKeyFrames);
        AssetDatabase.CreateAsset(clip, animPath);
        AssetDatabase.ForceReserializeAssets(new[] { animPath });
    }

    public static string GetAnimSavePath(string inputPath)
    {
        var result = inputPath.Replace("Art", "Animation").Replace(".png", ".anim");
        Debug.Log($"Anim path : {result}".ToColor(Color.cyan));
        var fileName = Path.GetFileName(result);
        var parentPath = result.Replace(fileName, string.Empty);
       
        //Write your own method to create directory here
        Utilities.CreateDirectoryIfNotExist(Path.Combine(Utilities.GetCurrentDir, parentPath));
        return result;
    }
}

public static class TOOLANIMCONFIG
{
    public const int ANIM_FRAME_RATE = 30;
    public const float FRAME_TIME = 0.066f;
}

#endif

@Peter1508 I really wanted to avoid doing it in two steps, especially because I’m losing information (frame count) if I do it later on - but perhaps your solution is the only one that works here. I’ll look into it and give it my own twist, I have an idea or two.

But generally, has nobody else really solved this? I can’t imagine this just doesn’t have a solution - but even asking the same question on stackoverflow and other pages didn’t net me any further advice (or any comment at all). It strikes me as weird, I hope I can get a clean fix for this down the line since animation-creation is the most time-consuming aspect of this whole thing right now.

Hi there!

Sorry for the late reply,
Based on your script, here is a solution I can offer you that I think is doing what you’re looking for in one import.
Things that I can tell are not working and cause you issues:

  • AssetDatabase.ForceReserializeAssets called during the AssetPostprocessor - this should be forbidden. We have a caching system in place for imported assets and changing the global state of the project (the meta file in your Assets folder in this case) during the import will break the cache and will end up with out of sync import results at one point or another during your development.
    I’m solving it in my script by loading the raw image manually during OnPreprocessTexture so that I can set all the settings in one shot.
  • In your first script, you’re also using AssetDatabase.CreateAsset, which can’t work properly for the same reason stated above. You’ve fixed it by doing a separate manual method in your second version, which is fine but I can imagine that this manual step is annoying. In my version, I’m using context.AddObjectToAsset (which wasn’t properly supported until this bugfix land)
  • In your first script, you were creating a new asset referencing the Sprites created during the import. Every UnityEngine.Object created during an import don’t have correct references until the import is done, so using them as references during an AssetPostprocessor won’t work, unless they are referenced by an object added to the context with AddObjectToAsset (in that case, references are resolved and fixed at the end of the import process when the final ids are being assigned).

But less talking, more results:

Here is the script I’m using to get to that result (thanks @Peter1508 for all of the sprites split and animation bindings part!)

using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;

public class TextureToSpriteAnimation : AssetPostprocessor
{
    private const float ClipFrameRate = 5f;
    private const float SpriteVerticalSplit = 1;
    private const float SpriteHorizontalSplit = 4;
   
    private bool shouldCreateAnimation = false;
   
    /// <summary>
    /// All import settings in the texture import that impact how the texture is generated
    /// need to be changed here to take effect during the import.
    /// </summary>
    private void OnPreprocessTexture()
    {
        // let's filter out assets that shouldn't be impacted (everything in packages specifically, and we only want assets with @)
        if (!assetPath.StartsWith("Assets/") || !Path.GetFileNameWithoutExtension(assetPath).Contains("@"))
        {
            return;
        }

        // manually load the texture here so that we can setup the sprites in one import.
        var texture = new Texture2D(1, 1);
        texture.LoadImage(File.ReadAllBytes(assetPath));
        // Figure out rects for the sprites using the sprite utility
        Rect[] rects = InternalSpriteUtility.GenerateGridSpriteRectangles(
            texture,
            Vector2.zero,
            new Vector2(texture.width/SpriteHorizontalSplit, texture.height/SpriteVerticalSplit),
            Vector2.zero,
            false
        );
        // don't forget to unload the texture, or we're leaking memory...
        Object.DestroyImmediate(texture);

        // Because we are using a static value here, we need to make sure the asset re-import if that static value is changed
        context.DependsOnCustomDependency("TextureToSpriteAnimation/SpriteVerticalSplit");
        context.DependsOnCustomDependency("TextureToSpriteAnimation/SpriteHorizontalSplit");
       
        // setup the texture importer settings now
        var textureImporter = (TextureImporter) assetImporter;
        textureImporter.textureType = TextureImporterType.Sprite;
        textureImporter.spriteImportMode = SpriteImportMode.Multiple;
        // let's get a nice name for the sprites.
        var assetName = Path.GetFileNameWithoutExtension(assetPath).Split('@')[0];
        // set all sprites into the current import settings so that they are being processed right now.
        int index = 0;
        textureImporter.spritesheet = rects.Select(rect => new SpriteMetaData
        {
            pivot = Vector2.down,
            alignment = (int)SpriteAlignment.BottomCenter,
            rect = rect,
            name = assetName + "_" + ++index
        }).ToArray();
       
        // We're done here, the import settings are set to multi sprites and the sprites data are filed.
        // That means the TextureImporter will be able to generate the Texture2D and the Sprites right after.
       
        // We are flagging that the current postprocessor instance has to create the animation.
        // We need that because we don't know if another Sprite texture may be imported and will trigger OnPostprocessSprites
        // but didn't get to that point because of our first filter.
        shouldCreateAnimation = true;
    }

    /// <summary>
    /// Because the texture type was set to sprite and the sprite data where provided in OnPreprocessTexture,
    /// We know this method is going to be called on our sprite now.
    /// </summary>
    private void OnPostprocessSprites(Texture2D texture, Sprite[] sprites)
    {
        // Don't do anything if it didn't validate the filter in OnPreprocessTexture.
        if (!shouldCreateAnimation)
            return;
       
        // now that we have the sprites, let's create the animation
        AnimationClip clip = new AnimationClip { frameRate = ClipFrameRate };
        var settings = AnimationUtility.GetAnimationClipSettings(clip);
        settings.loopTime = true;
        AnimationUtility.SetAnimationClipSettings(clip, settings);
        clip.name = Path.GetFileNameWithoutExtension(assetPath).Replace('@', '_');
       
        EditorCurveBinding spriteBinding = new EditorCurveBinding
        {
            type = typeof(SpriteRenderer), path = "", propertyName = "m_Sprite"
        };
        ObjectReferenceKeyframe[] spriteKeyFrames = new ObjectReferenceKeyframe[sprites.Length];
        for (int i = 0; i < sprites.Length; i++)
        {
            spriteKeyFrames[i] = new ObjectReferenceKeyframe
            {
                time = i * 1f/ClipFrameRate,
                value = sprites[i] // The frames cease to exist after this function call
            };
        }
        AnimationUtility.SetObjectReferenceCurve(clip, spriteBinding, spriteKeyFrames);
       
        // Because we are using a static value here, we need to make sure the asset re-import if that static value is changed
        context.DependsOnCustomDependency("TextureToSpriteAnimation/ClipFrameRate");

        // Now that the animation is created, we have to save it as a subasset of the texture.
        // using AssetDatabase.CreateAsset is forbidden at this point
        // because creating other files during an import is an undeterministic side effect that would break the import caching.
        context.AddObjectToAsset("anim", clip);
    }

    /// <summary>
    /// This is very important to setup.
    /// Any static value or custom data outside of the .meta file or the texture file itself
    /// that is used during the postprocessor need to be registered as a custom dependency
    /// so that assets impacted by theses values can re-import when they change.
    /// </summary>
    [InitializeOnLoadMethod]
    static void SetupConstantDependencies()
    {
        AssetDatabase.RegisterCustomDependency("TextureToSpriteAnimation/ClipFrameRate", Hash128.Compute(ClipFrameRate));
        AssetDatabase.RegisterCustomDependency("TextureToSpriteAnimation/SpriteVerticalSplit", Hash128.Compute(SpriteVerticalSplit));
        AssetDatabase.RegisterCustomDependency("TextureToSpriteAnimation/SpriteHorizontalSplit", Hash128.Compute(SpriteHorizontalSplit));
    }
}