This tutorial will demonstrate an easy to use ‘Sprite Sheet Animated Texture’ Implementation for unity. The tutorial is intended for people familiar with the unity GUI and at least introduced to Unity’s scripting features.
The 2019 version of Unity has a Sprite Editor tool specifically intended to work with sprite sheets. This tool involves identifying and ordering the frames of each sprite and treats them as individual textures that can be modified separately. This is an excellent way to work with sprites and is intended for use with non-equally-spaced sprite sheets. This Animated Sprite Sheet script is for when you do have equally-spaced sprite sheets and want to shave a few minutes off your work flow. This is especially useful for rectangular textures that might be used in the game, such as TV screens or advertisements; Or if your method for packing textures into the sprite sheet doesn’t optimize to minimize texture size.
Where to start
The first thing we need, for making a Sprite Sheet Animated texture, is a Sprite Sheet. A Sprite Sheet is a sequence of images evenly spaced in a single texture. The images are arranged depending on which direction you want to read the animation in. As a well known example, lets use Mario’s walking animation.

Mario is the property of Nintendo and I do not claim to own any rights to the character. These images are courtesy of the spriter’s resource. This rendition of Mario is merely used here as an example.

5 images arranged in a line on a transparent background and spaced evenly. But your sprite sheet can be any number of images, but bear in mind that the unity will scale down large textures depending on your import settings and target platform settings, this scaling can result in blurry images once the texture is cut up into frames. Fortunately there is no need to limit yourself to only 1 row, space the frames equally in as many rows and columns as you’d like.
It is entirely possible you still can’t fit all the frames you want onto a texture, fortunately the class described below allows quick swapping between animations; So if 16 frames is not enough, you can split up the animation into 2 or more sprite sheets, each animations swapping to the next as soon as it ends.
Once we have a sprite sheet, we need a place to display it. The most likely answer is to simply use a plane, but if you wanted, you could apply an animated texture of this form to any kind of UV mapped model.
Using the scripts
Now download the scripts or project here, and add the scripts and sprite sheet to the unity project in which you want to make use of them.

1- Place a plane into the scene and drag the script onto it. In the values of the script, set the number of animations you intend to use. You will need at least one animation. If you create more than one animation, set the variable for ‘Current
Active Animation’ to the index of the animation you want to be active at the start.

2- Add the texture containing your sprite sheet to your project and drag it onto the texture variable or click the circle and select it from the menu. Now that the script knows about your texture you may also want the object to know about it. The
material in the render variables, should be replaced with a new material. The material you create for this purpose can have shaders like transparency, which is useful to most sprites. It can be self lit, which is useful for explosion sprites or interface animations. Or even get creative with bump mapping or custom shaders to add cool effects to your flat animated object.
3- Next we set how many frames the texture has Horizontally and Vertically. If you added the texture to a material attached to the object you should be able to easily see how many frames the texture has.

You may also notice that Mario is upside down, this is because the default plane in unity has texture coordinates going from 0,0 in the bottom right, to 1,1 in the top left. Which is exactly the opposite of the texture coordinates as they are stored when imported, which go from 0,0 in the top left to 1,1 in the bottom right. I do not know why unity diverts from the standards in this case but it is an easy enough fix if you mirror your texture, either by making the texture mirrored vertically

4- Set the frames your animation will use. You do not need to use all the frames in the sprite sheet for 1 animation, you can set the start between 1 to the last frame, and end can be 1 to the last frame. This allows you to start the first animation at a frame other than the first, or to leave empty space and start each animation on a different row. The implementation also supports using a starting frame with a higher number than the ending frame, this will make the animation step through the frames in reverse. Then we set the animation duration, the time in seconds, that it takes to get from the starting frame to the ending frame of the animation. Now we have set up what happens while the animation is running we need to
consider what happens when it ends.
Loop – The animation starts over from the first frame when it ends, looping forever.
Pingpong – The animation plays once normally, then again in reverse, before starting again forwards.
Go To Animation On End – The index of an animation to go to when the animation ends and no form of restarting is selected. In order to freeze the animation on the last frame, make sure no form of looping is selected and the Go To index
is set to -1.
Example
This is a little demo running using WebGL, the rain, passing cars, mario and the billboard are all using animated texture sprite sheets. Mario might be looking a little blurry due to WebGL anti-aliasing being confused why his texture has so few pixels. But that problem fixes itself when you download the demo and run it using your system’s graphics drivers rather than WebGL.
These are the scripts used to create the above example with only 3 textures, and minimal overhead cost due to swapping which textures are loaded into available graphics memory. Animated Texture Object This base class handles the public functions of the object with the animated texture and a reference of it should be
<xmp>/************************************************************************************* /* ANIMATED TEXTURE OBJECT /* The actual class that will be responsible for animating the object. /*************************************************************************************/ public class AnimatedTextureObject : MonoBehaviour { [SerializeField] public TextureAnimation[] animations; public int currentActiveAnimation = 0; private bool mActiveAnimation = false; private bool mXFlipped = false; void Start() { StartAnimation( currentActiveAnimation ); } public bool StartAnimation( int aAnimationIndex ) { // Make sure to only play animations that we actually have data for. if( animations.Length > aAnimationIndex ) { currentActiveAnimation = aAnimationIndex; // Different animations on an object can read from different textures, so we set the texture to the one we need. gameObject.GetComponent<Renderer>().material.SetTexture( "_MainTex", animations[aAnimationIndex].getTexture() ); animations[aAnimationIndex].updateUVsForMaterial( gameObject.GetComponent<Renderer>().material ); // The animation might have been playing previously, we probably want to reset which frame it was at back to the first. animations[aAnimationIndex].Stop(); animations[aAnimationIndex].Play(); mActiveAnimation = true; } else { mActiveAnimation = false; } return mActiveAnimation; } void Update() { if( mActiveAnimation == true ) { bool lAnimationEnded = false; // The isVisible flag is an 'is object on screen' check, no need to recalculate UV on something we can't see. if (GetComponent<Renderer>().isVisible == true ) lAnimationEnded = animations[currentActiveAnimation].Update( Time.deltaTime, gameObject.GetComponent<Renderer>().material, mXFlipped ); else lAnimationEnded = animations[currentActiveAnimation].UpdateTime( Time.deltaTime ); if( lAnimationEnded == true ) StartAnimation( animations[currentActiveAnimation].goToAnimationOnEnd ); } } public void Play() { if( animations.Length > currentActiveAnimation ) { animations[currentActiveAnimation].Play(); } } public void Pause() { if( animations.Length > currentActiveAnimation ) { animations[currentActiveAnimation].Pause(); } } public void Stop() { if( animations.Length > currentActiveAnimation ) { animations[currentActiveAnimation].Stop(); mActiveAnimation= false; } } public void setReverseHorizontalDirection( bool to ) { mXFlipped = to; } } </xmp> |
Texture Animation
This is where the clout of the code for this is. This class is tasked with storing the presets for its animation and the current state time that animation has been playing. It recalculates UV data each frame for animations if necessary and has multiple update methods for when a full update isn’t necessary.
<xmp>/************************************************************************************* /* TEXTURE ANIMATION /* Internal class, used for exposing animation variables in the viewer. /* This is only public because it needs to be for the viewer, not because it needs to be used elsewhere. /*************************************************************************************/ [System.Serializable] public class TextureAnimation { [SerializeField] public AnimatedTexture animatedTex; public int startAtFrame = 0; public int endAtFrame = 0; public float animationDuration = 1; public bool loop = true; public bool pingpong = false; public int goToAnimationOnEnd = -1; private float mTimePlayed = 0; private bool mPlaying = false; private int mDeltaFrames = 0; private float mTimePerFrame = 0; private Vector2[] mUVDataThisFrame; private float mTimeToUseForFrameCalc; private bool mSendEndedNotification = false; // precalculate what we can public void InitTexture() { animatedTex.Init(); mDeltaFrames = endAtFrame - startAtFrame; mTimePerFrame = animationDuration / (mDeltaFrames + 1); // +1 frame because we want the final frame to stay on screen for equal time // make sure the texture is ready at the first frame mUVDataThisFrame = animatedTex.getUVsForFrame( startAtFrame ); } public void Play() { mPlaying = true; } public void Pause() { mPlaying = false; } public void Stop() { Pause(); mTimePlayed = 0; } // The animation update method. // This increments the internal time and sets new UV coordinates for the material passed. // It returns true when the animation is ready to swap to the next animation. public bool Update( float aDeltaTime, Material aMaterial, bool aXFlipped ) { UpdateTime( aDeltaTime ); if( mPlaying == true ) { // only update the UVs if there is an animation to get new UVs from if( mDeltaFrames != 0 ) { int numberOfFramesElapsed; if( mDeltaFrames > 0 ) { numberOfFramesElapsed = Mathf.FloorToInt(mTimeToUseForFrameCalc / mTimePerFrame); } else { // unfortunately floor and ceil do exactly the opposite to negative numbers numberOfFramesElapsed = Mathf.CeilToInt(mTimeToUseForFrameCalc / mTimePerFrame); } int atFrame = startAtFrame + numberOfFramesElapsed; if( mTimePlayed >= animationDuration && pingpong == false ) mUVDataThisFrame = animatedTex.getUVsForFrame( endAtFrame ); else mUVDataThisFrame = animatedTex.getUVsForFrame( atFrame ); } if( aXFlipped == true ) { // go forward 1 frame mUVDataThisFrame[0].x += mUVDataThisFrame[1].x; // reverse the direction of X mUVDataThisFrame[1].x = -mUVDataThisFrame[1].x; } updateUVsForMaterial( aMaterial ); } return mSendEndedNotification; } public void updateUVsForMaterial( Material aMaterial ) { aMaterial.SetTextureOffset( "_MainTex" , mUVDataThisFrame[0] ); aMaterial.SetTextureScale( "_MainTex" , mUVDataThisFrame[1] ); } public bool UpdateTime( float aDeltaTime ) { mSendEndedNotification = false; if( mPlaying == true ) { mTimePlayed += aDeltaTime; mTimeToUseForFrameCalc = mTimePlayed; // check if the animation has ended if( mTimePlayed > animationDuration ) { // loop simply starts over at the beginning. if( loop == true ) { mTimePlayed -= animationDuration; mTimeToUseForFrameCalc -= animationDuration; } // pingpong does not reset the time else if( pingpong == true ) { float lClippedDuration = animationDuration - (mTimePerFrame * 2); // the last frame and first frame are not needed float lPingPongDuration = animationDuration + lClippedDuration; if( mTimePlayed <= lPingPongDuration ) { // instead pingpong lets the animation continue, but any overtime becomes reverse time mTimeToUseForFrameCalc = mTimePerFrame + lClippedDuration - ( mTimePlayed - animationDuration); } else { // after the overtime equals the duration, it starts over from the beginning. mTimePlayed -= lPingPongDuration; mTimeToUseForFrameCalc -= lPingPongDuration; } } else { // the animation is simply over, so we freeze it at the last frame mTimePlayed = animationDuration; mTimeToUseForFrameCalc = animationDuration; if( goToAnimationOnEnd >= 0 ) { mSendEndedNotification = true; } } } } return mSendEndedNotification; } public Texture getTexture() { InitTexture(); // Make sure the texture is ready before passing it. return animatedTex.textureWithFrames; } }</xmp> |
<xmp>/************************************************************************************* /* ANIMATED TEXTURE /* Internal class, used for exposing texture related variables in the viewer. /* This is only public because it needs to be for the viewer, not because it needs to be used elsewhere. /*************************************************************************************/ [System.Serializable] public class AnimatedTexture { public Texture textureWithFrames; public int numberOfFramesHorizontal = 1; public int numberOfFramesVertical = 1; public bool needsFlip = true; private float mOneFrameUstep; private float mOneFrameVstep; // Precalculate the stepsize to save us from having to recalculate it each frame. public void Init() { mOneFrameUstep = 1.0f / (float)numberOfFramesHorizontal; mOneFrameVstep = 1.0f / (float)numberOfFramesVertical; } // Get Frame UV's method. // Returns an array containing 2 Vector2's. // The first is the bottom left texture coordinate of the requested frame. // The second is the distance to the top right coordinate. // Both return values are in 0 to 1, texture coordinates. public Vector2[] getUVsForFrame( int aFrameNumber ) { float lUToReturn = 0; float lVToReturn = 0; int atRow = aFrameNumber / (numberOfFramesHorizontal); if( needsFlip == true && aFrameNumber % numberOfFramesHorizontal == 0 ) { // When flipped, we are reading each frame right to left. // So for the frame at the end of each row, // even though the horizontal coordinate is just past the end of the row // we will be reading the pixels to the left of that coordinate. // Meaning we jump back 1 row, but only for the last frame on the row. atRow -= 1; } int atCollom = aFrameNumber - ( atRow * numberOfFramesHorizontal); lUToReturn = mOneFrameUstep * atCollom; lVToReturn = mOneFrameVstep * atRow; Vector2[] toReturn = new Vector2[2]; if( needsFlip == true ) { toReturn[0] = new Vector2( lUToReturn, 1.0f - lVToReturn ); toReturn[1] = new Vector2( -mOneFrameUstep, -mOneFrameVstep); } else { toReturn[0] = new Vector2( lUToReturn, lVToReturn ); toReturn[1] = new Vector2( mOneFrameUstep, mOneFrameVstep); } return toReturn; } }</xmp> |
All 3 classes come together in 1 file, because both the serializable subclasses never need to be
used outside of the context of the Animated Texture Object. They are public because the inspector
view requires them to be public so they can be treated as variables, but should be considered
private for all other purposes.
Downloads
Demo Webpage and Library
Download project
Download script only