SpriteClip2 - A Free Versatile Spritesheet Animation Module

Introduction

SpriteClip2 is a module that simplifies the process of creating and animating both very simple and complex sprites in Roblox.

This is a direct successor to my older SpriteClip module. It was fairly popular, but I was never happy with its lack of features and the amount of quirks. The API is very similar, but uses a different case for personal reasons. Paused sprites are now properly handled and no longer have to be destroyed to avoid looping through hundreds of sprites every frame.

To quickly see it in action, place the module into StarterPlayerScripts, open the config module inside the SpriteClip2 module, and set IsDemoMode to true.

Getting started

Roblox doesn’t allow users to upload images bigger than 1024x1024. If your image is either taller or wider than 1024 pixels, it will be scaled down.
If you are not sure, use this link to download your uploaded image and check its final resolution:
https://assetdelivery.roblox.com/v1/asset/?id=assetidhere

A more detailed tutorial can be found here.
And here’s the place file from that tutorial:
SpriteClip2 github example.rbxl (67.5 KB) (everything important is in StarterGui)

To achieve proper alignment, you’ll have to find these values:

  • spriteSize - Exact size of the sprites in pixels.
  • edgeOffset - Distance between the top-left sprite and the top-left corner of the image in pixels.
  • spriteOffset - Distance between individual sprites in pixels.
  • spriteCount - Exact number of sprites.
  • columnCount - How many sprites are next to each other horizontally.

Once you’ve uploaded your image, create a new ImageLabel. Require the module and create a new ImageSprite, while providing it with the correct properties. Then call :Play() to run the animation.

Example code
local SpriteClip = require(script.Parent);

local label = Instance.new("ImageLabel");
label.Size = UDim2.fromScale(300,300);
label.Parent = Instance.new("ScreenGui", game:GetService("Players").LocalPlayer:WaitForChild("PlayerGui"));

local sprite = SpriteClip.ImageSprite.new({
	adornee = label,
	spriteSheetId = "rbxassetid://104239803996382";
    spriteSize = Vector2.new(150,150);
    spriteCount = 12;
    columnCount = 6;
    frameRate = 3;
    isLooped = true;
});

sprite:Play();

Documentation

There are 5 different types of sprites you can create, as well as some other QOL features.

The 3 basic sprites are ImageSprite, EditableSprite and CompatibilitySprite:

  • ImageSprite - Automatically applies the spritesheet to its adornee ImageLabel/ImageButton as it plays.
  • EditableSprite - Does the same but with EditableImages (view notes below).
  • CompatibilitySprite - Its behavior is almost identical to the old SpriteClip module, so it can serve as a drop-in replacement.

The 2 more advanced ones are ScriptedImageSprite and ScriptedEditableSprite. Unlike the previous 3, these do not apply the spritesheet automatically and will instead call an onRenderCallback function every frame. This way, you get to chose what portion of the spritesheet you want to render at any time, which makes them especially useful for dynamic sprites, such as for characters.

The Scheduler is an object used internally by the sprite classes, but can also be used to pause/resume render calls for all sprites (e.g. can be used for pause menus) as well as to get their framerate signals.

API

CompatibilitySprite API is identical to the older module.

ImageSprite
    -- creates a new ImageSprite
    MainModule.ImageSprite.new(props:ImageSpriteProps)->(ImageSprite)

    -- provides initial properties to the sprite, all properties are optional
    ImageSpriteProps = {    
        adornee:            ImageLabel|ImageButton?;
        spriteSheetId:      string?;
        currentFrame:       number?;
        spriteSize:         Vector2?;
        spriteOffset:       Vector2?;
        edgeOffset:         Vector2?;
        spriteCount:        number?;
        columnCount:        number?;
        frameRate:          number?;
        isLooped:           boolean?;
    }

    -- format: name: type -- (READONLY) [default] description
    ImageSprite = {
        -- properties
        adornee:            ImageLabel|ImageButton?;    -- [nil] the image label/button to apply the sprite to, sprite does nothing if nil
        spriteSheetId:      string;                     -- [""] the asset id of the sprite sheet, sprite does nothing if ""
        currentFrame:       number;                     -- READONLY [1] index of the frame that is currently visible (starts from 1)
        spriteSize:         Vector2;                    -- [0,0] size of the individual sprites in pixels
        spriteOffset:       Vector2;                    -- [0,0] offset between individual sprites in pixels
        edgeOffset:         Vector2;                    -- [0,0] offset from the image's (0,0) corner in pixels
        spriteCount:        number;                     -- [0] total number of sprites
        columnCount:        number;                     -- [0] total number of columns (left-to-right sprite count)
        frameRate:          number;                     -- [30] max frame rate the sprite can achieve when playing (can be any number, but will be clamped by RenderStepped frame rate)
        isLooped:           boolean;                    -- [true] if the sprite will loop while playing (stops at last frame otherwise)
        isPlaying:          boolean;                    -- READONLY [false] whether the sprite is playing or not
        -- methods
        Play:   (self:ImageSprite, playFrom:number?)->(boolean);   -- plays the animation
        Pause:  (self:ImageSprite)->(boolean);                     -- pauses the animation
        Stop:   (self:ImageSprite)->(boolean);                     -- pauses the animation and resets the current frame to 1
        SetFrame:(self:ImageSprite, frame:number)->();             -- manually sets the current frame
        Advance:(self:ImageSprite)->();                            -- manually advances to the next frame, or 1 if last
        GetSignal:(self:ImageSprite, signalType:SignalType)->(RBXScriptSignal) -- view the SignalTypes category
    }

EditableSprite
    -- creates a new EditableSprite
    MainModule.EditableSprite.new(props:EditableSpriteProps)->(EditableSprite)

    -- provides initial properties to the sprite, all properties are optional
    EditableSpriteProps = {
        inputImage:         EditableImage|string?;
        outputImage:        EditableImage?;
        outputPosition:     Vector2?;
        currentFrame:       number?;
        spriteSize:         Vector2?;
        spriteOffset:       Vector2?;
        edgeOffset:         Vector2?;
        spriteCount:        number?;
        columnCount:        number?;
        frameRate:          number?;
        isLooped:           boolean?;
    }

    -- format: name: type -- (READONLY) [default] description
    EditableSprite = {
        -- properties
        inputImage:         EditableImage?; -- READONLY [nil] EditableImage to read the pixel data from, change using LoadInputImage
        outputImage:        EditableImage;  -- [nil] EditableImage to write the pixel data to, can be replaced with a different EditableImage
        outputPosition:     Vector2;        -- [0,0] render offset for the output image, useful for storing multiple sprites as an atlas
        currentFrame:       number;         -- READONLY [1] index of the frame that is currently visible (starts from 1)
        spriteSize:         Vector2;        -- [0,0] size of the individual sprites in pixels
        spriteOffset:       Vector2;        -- [0,0] offset between individual sprites in pixels
        edgeOffset:         Vector2;        -- [0,0] offset from the image's top-left corner in pixels
        spriteCount:        number;         -- [0] total number of sprites
        columnCount:        number;         -- [0] total number of columns (left-to-right sprite count)
        frameRate:          number;         -- [30] max frame rate the sprite can achieve when playing (can be any number, but will be clamped by RenderStepped frame rate)
        isLooped:           boolean;        -- [true] if the sprite will loop while playing (stops at last frame otherwise)
        isPlaying:          boolean;        -- READONLY [false] whether the sprite is playing or not
        -- methods
        Play:   (self:EditableSprite, playFrom:number?)->(boolean);                 -- plays the animation
        Pause:  (self:EditableSprite)->(boolean);                                   -- pauses the animation
        Stop:   (self:EditableSprite)->(boolean);                                   -- pauses the animation and resets the current frame to 1
        SetFrame:(self:EditableSprite, frame:number)->();                           -- manually sets the current frame
        Advance:(self:EditableSprite)->();                                          -- manually advances to the next frame, or 1 if last
        LoadInputImage: (self:EditableSprite, newInput:EditableImage|string)->();   -- async if given a string, replaces the input image with a new one
        GetSignal:(self:ImageSprite, signalType:SignalType)->(RBXScriptSignal)       -- view the SignalTypes category
    };
CompatibilitySprite
    -- creates a new CompatibilitySprite
    MainModule.CompatibilitySprite.new()->(CompatibilitySprite)

    CompatibilitySprite = {
        -- properties
        Adornee				:Instance?;
        SpriteSheet			:string?;
        InheritSpriteSheet	:true;
        CurrentFrame		:number;
        SpriteSizePixel		:Vector2;
        SpriteOffsetPixel	:Vector2;
        EdgeOffsetPixel 	:Vector2;
        SpriteCount  		:number;
        SpriteCountX  		:number;
        FrameRate  			:number;
        FrameTime           :number;
        Looped  			:boolean;
        State				:boolean;
        -- methods
        Play:(self:CompatibilitySprite)->(boolean);
        Pause:(self:CompatibilitySprite)->(boolean);
        Stop:(self:CompatibilitySprite)->(boolean);
        Advance:(self:CompatibilitySprite, advanceby:number)->();
        Destroy:(self:CompatibilitySprite)->();
        Clone:(self:CompatibilitySprite)->(CompatibilitySprite);
    }
ScriptedImageSprite
    -- creates a new ScriptedImageSprite
    MainModule.ScriptedImageSprite.new(props:ScriptedImageSpriteProps)->(ScriptedImageSprite)

    -- format: name: type -- (READONLY) [default] description
    ScriptedImageSprite = {
        -- properties
        adornee:            ImageLabel|ImageButton?;    -- [nil] the image label/button to apply the sprite to, sprite does nothing if nil
        spriteSheetId:      string;                     -- [""] the asset id of the sprite sheet, sprite does nothing if ""
        currentFrame:       number;                     -- READONLY [1] index of the frame that is currently visible (starts from 1)
        spriteSize:         Vector2;                    -- [0,0] size of the individual sprites in pixels
        spriteOffset:       Vector2;                    -- [0,0] offset between individual sprites in pixels
        edgeOffset:         Vector2;                    -- [0,0] offset from the image's (0,0) corner in pixels
        frameRate:          number;                     -- [30] max frame rate the sprite can achieve when playing (can be any number, but will be clamped by RenderStepped frame rate)
        isPlaying:          boolean;                    -- READONLY [false] whether the sprite is playing or not
        -- methods
        Play:       (self:ScriptedImageSprite)->(boolean);               -- plays the animation
        Pause:      (self:ScriptedImageSprite)->(boolean);               -- pauses the animation
        SetFrame:   (self:ScriptedImageSprite, frame:Vector2)->();       -- manually sets the current frame
        Advance:    (self:ScriptedImageSprite)->();                      -- manually advances to the next frame, or 1 if last
        -- callbacks
        onRenderCallback: (self:ScriptedImageSprite)->()?;           -- called every frame when playing
    };

    -- provides initial properties to the sprite, all properties are optional
    ScriptedImageSpriteProps = {
        adornee:            ImageLabel|ImageButton?;
        spriteSheetId:      string?;
        currentFrame:       number?;
        spriteSize:         Vector2?;
        spriteOffset:       Vector2?;
        edgeOffset:         Vector2?;
        frameRate:          number?;
        isLooped:           boolean?;
        onRenderCallback:   (self:ScriptedImageSprite)->()?;
    }
ScriptedEditableSprite
    -- creates a new ScriptedEditableSprite
    MainModule.ScriptedEditableSprite.new(props:ScriptedEditableSpriteProps)->(ScriptedEditableSprite)

    ScriptedEditableSprite = {
        -- properties
        inputImage:         EditableImage?; -- READONLY [nil] EditableImage to read the pixel data from, change using LoadInputImage
        outputImage:        EditableImage;  -- [nil] EditableImage to write the pixel data to, can be replaced with a different EditableImage
        outputPosition:     Vector2;        -- [0,0] render offset for the output image, useful for storing multiple sprites as an atlas
        currentFrame:       number;         -- READONLY [1] index of the frame that is currently visible (starts from 1)
        spriteSize:         Vector2;        -- [0,0] size of the individual sprites in pixels
        spriteOffset:       Vector2;        -- [0,0] offset between individual sprites in pixels
        edgeOffset:         Vector2;        -- [0,0] offset from the image's top-left corner in pixels
        frameRate:          number;         -- [30] max frame rate the sprite can achieve when playing (can be any number, but will be clamped by RenderStepped frame rate)
        isPlaying:          boolean;        -- READONLY [false] whether the sprite is playing or not
        -- methods -- removed: Stop, 
        Play:   (self:ScriptedEditableSprite, playFrom:number?)->(boolean);                 -- plays the animation
        Pause:  (self:ScriptedEditableSprite)->(boolean);                                   -- pauses the animation
        SetFrame:(self:ScriptedEditableSprite, frame:number)->();                           -- manually sets the current frame
        Advance:(self:ScriptedEditableSprite)->();                                          -- manually advances to the next frame, or 1 if last
        LoadInputImage: (self:ScriptedEditableSprite, newInput:EditableImage|string)->();   -- async if given a string, replaces the input image with a new one
        -- callbacks
        onRenderCallback: (self:ScriptedEditableSprite)->()?;       -- called every frame when playing
    };

    -- provides initial properties to the sprite, all properties are optional
    ScriptedEditableSpriteProps = {
        inputImage:         EditableImage|string?;
        outputImage:        EditableImage?;
        outputPosition:     Vector2?;
        currentFrame:       number?;
        spriteSize:         Vector2?;
        spriteOffset:       Vector2?;
        edgeOffset:         Vector2?;
        frameRate:          number?;
        isLooped:           boolean?;
        onRenderCallback:   (self:ScriptedEditableSprite)->()?;
    }
Scheduler
GetPreRenderSignal : (self, framerate:number)->(RBXScriptSignal) -- fires right before sprites are rendered
GetOnRenderSignal : (self, framerate:number)->(RBXScriptSignal) -- fires at the same time sprites are rendered
GetPostRenderSignal : (self, framerate:number)->(RBXScriptSignal) -- firest after sprites are rendered
GetSignal:  (self, framerate:number)->(RBXScriptSignal)  -- deprecated, use GetOnRenderSignal  instead
Pause:      (self)->()                                   -- pauses render events for all sprites
Resume:     (self)->()                                   -- resumes paused render events for all sprites
SignalTypes
(ImageSprite | EditableSprite):GetSignal(signalType:SignalType) -> RBXScriptSignal

-- format: "SignalName" | -- (callback arguments) - description
type SignalType =
    "FrameLast" |   -- () - fires when the sprite hits its last frame
    "Looped" |      -- () - fires when a looped sprite goes from its last to first frame
    "FrameChanged" |-- () - fires on frame change
    "PlayCalled" |  -- () - fires when :Play() is called if the sprite isn't playing
    "PauseCalled" | -- () - fires when :Pause() is called if the sprite is playing
    "StopCalled" |  -- () - fires when :Stop() is called if the sprite is playing
    "StaticChanged" -- (propName:string) - fires when a static property has changed, such as: such as: inputImage, outputImage, outputPosition, spriteSize, spriteOffset, edgeOffset, spriteCount, columnCount, frameRate, isLooped

-- An example that prints the current frame every time it's advanced
local signal = sprite:GetSignal("FrameChanged");
signal:Connect(function() print(signal.currentFrame); end);

Links

Github: GitHub - nooneisback/luau-spriteclip2
Store: https://create.roblox.com/store/asset/111579633111377/SpriteClip2
The github tutorial place file: tester.rbxl (62.8 KB) (everything important is in StarterGui)

Notes

Please report any bugs or other issues you find.

Updates

  • Added GetPreRenderSignal, GetOnRenderSignal and GetPostRenderSignal methods to the Scheduler.
  • Added a link to a more detailed tutorial in the Getting started section
  • Updated the module to support the new EditableImages API
  • Now sprites will call :Stop if their adornee is destroyed (does not trigger if the adornee property is set to nil)
  • Added a signal system that doesn’t add any overhead if unused
8 Likes

This is simmilar to my paper2d plugin and framework for 2d game development

Exactly the same or the same idea?

I can`t access the link for the model

1 Like

Interesting, for whatever reason Roblox didn’t apply the change to distribute on the marketplace when I uploaded the update. Should be fixed now.

Yeah it works now, would you mind making a visual tutorial that goes over this module if you got the time?

The main post already has a link to a more detailed tutorial with images

My connection is impossibly slow, so it’d take about 30 minutes just to upload a 640p 10min video.

How does this module handle its disconnections? cuz i made a test script where i made 250 img labels and the peak script performance reached 3-4 % but after the img labels got destroyed the script performance seemed to remain at a constant 1-2%.

*local SpriteClip = require(game.ReplicatedStorage.Modules.Other.SpriteClip)*

*local scrgui = script.Parent*

for i = 1, 250 do
	local imgLabel = scrgui.ImageLabel:Clone()
	imgLabel.Position = UDim2.fromScale(math.random(), math.random())
	imgLabel.Parent = scrgui
	game.Debris:AddItem(imgLabel, 10)
	
	local newSprite = SpriteClip.ImageSprite.new({
		adornee = imgLabel;
		spriteSheetId = "rbxassetid://16860267787";
		spriteSize = Vector2.one * 256;
		spriteCount = 16;
		columnCount = 4;
		frameRate = 30;
	})
	newSprite:Play()

	task.wait()
end
1 Like

Added this behavior just now. The sprites will call :Stop if the adornee is destroyed, but not if it’s set to nil. I didn’t want to add this behavior because this module was originally made before the Destroyed event was a thing, so differentiating between truly destroyed instances and just the parent being set to nil was impossible. You’ll have to update the module to get this behavior.

A very important feature in my opinion that should be added, is the ability to detect when a sprite has played once, right now there is the “isLooped” bool that u can set to false so it plays only once, but u cant detect when the sprite has finished playing, this would be a very importan feature bc in most cases i would play a sprite only 1 time and then i want to destroy the image

Added a signal system. To use it call sprite:GetSignal(signalType). In your case, you’d want sprite:GetSignal(“FrameLast”).

Do note though that you’ll have to wait for 1 frame time for the last frame to be visible before destroying the image.

sprite:GetSignal("FrameLast"):Connect(function()
    task.wait(1/sprite.frameRate);
    sprite.adornee:Destroy();
end)
1 Like

Thanks! Ill come back with further improvement suggestions if needed