In the Gamemaker Studio video game engine
(GMS), and presumably other 2D game engines,
game "sprites" are a collection of
subimages (also called frames, referred to by index in GMS via image_index
).
These subimages may represent frames of an animation, or collection of animations, that the game can cycle through. Frames within a sprite can also be used to create alternate versions of a static asset, such as recolors or random variants.
WARNING This tool permanently changes your image files. Only use it if your images are backed up somewhere. Take particular care when using recursive commands!
Spritely aims to clean up subimages before you import them into your game project, solving a few key problems:
- Edge interpolation artifacts (faint outlines around rendered sprites)
- Excessive padding (increases compiling time)
- Re-skinning via Gradient Maps
You may notice some border artifacts around your sprites, especially when the camera
is not positioned in a pixel-perfect way (e.g. in GMS when the
"Interpolate colors between pixels" is set). This is caused by the engine computing
a weighted average between the border pixel's color and the color of the
neighboring pixels on the texture page, which are transparent black or white
(rgba(0,0,0,0)
or rgba(255,255,255,0)
).
So if the edge of your sprite is yellow and you are rendering the sprite at
a subpixel position, you'll get a faint one-pixel-wide
border drawn around your image that is much darker or brighter than the original edge.
A tile (inset) showing edge-alias artifacts when the camera moves by subpixel (left) or full pixel (right). Artifacts are present in both cases, but much more pronounced with subpixel camera positioning.
Spritely identifies the edge pixels and creates a border around them that is the same color and mostly transparent, so that interpolation will not so dramatically impact the edges of your images. This process goes by various names and may already be available in your art creation tools. In Spine it's called "Bleed", in other contexts you might see it as flood-filling or edge-padding.
Note that you shouldn't be able to tell the difference by eye between the original image and a bled image.
It's likely that your subimages consist of something meaningful drawn inside a transparent rectangle. Excessive padding around the meaningful content adds more pixels that Gamemaker must process when creating texture pages, so removing that padding can dramatically speed up compiling.
Spritely crops your subimages to remove excess padding, but takes into account all subimages in doing so to ensure that they are all cropped in the exact same way. In effect, it creates a new bounding box based on the bounding boxes of all subimages of a sprite.
The above figure demonstrates how Spritely crops sprites. Panel A shows three subimages of the same sprite, where the main content of each subimage takes up only a small portion of the total subimage dimensions. Since the location of the content in each subimage is different, cropping each subimage individually would result in subimages of different sizes with inconsistent positioning relative to the original sprite. Panel B shows how Spritely creates a bounding box taking the content position of all subimages into account, with panel C showing the cropped output.
Clip Studio Paint, Photoshop, and other art creation software provide a "Gradient Map" concept. The idea is to start with a grayscale art asset, and then map positions along that grayscale spectrum onto colors, with computed values in between those positions making up the 'gradient'.
Spritely can batch-apply any number of gradient maps to your sprites. Sprite subimages are assumed to be in grayscale, but any pixels that are not grayscale (i.e. the RGB values differ from one another) are converted to grayscale before applying the gradient map.
(If you have an image that has a combination of grayscale and non-grayscale pixels, or are otherwise mapping from a non-grayscale image, you may get unexpected results.)
Provide gradient maps with a YAML file describing each mapping and its positional values. Positions are integers from 0-100, and colors are hexadecimal strings.
# "Skins" are the gradient mappings. Each one ends up being a folder
# into which mapped images are placed. There is an implicit "default"
# skin that doesn't do anything.
skins:
flux:
0: "000000"
100: "ffffff"
spooky:
0: "ff0000"
0: "ff00ff"
# "Groups" are collections of images to which skins are applied.
# If no groups are provided, all skins are assumed to apply to all images.
# If *any* groups are provided, images will only be skinned if they match a group.
# Matching patterns are regex, and must be directly usable by JavaScript's `new RegExp()` method. They are tested against the image filename (not the parent folder/sprite).
# Case is always ignored. An image could land in multiple groups, and in that case
# will be skinned with the skins from all matching groups. Groups have no impact on
# output location or filename. Images that land in no groups are copied into the "none"
# skin folder, without being changed.
groups:
- pattern: "^faceplate_" # Matches all image names starting with `faceplate_`
match: 'subimage' # Checks pattern against subimage name by default. Can be set to "sprite" to test at sprite level.
skins:
- "flux"
- "spooky"
- pattern: "^(?!eyes_)" # Matches anything that *doesn't* start with `eyes_` (using negative lookahead)
skins:
- "spooky"
If Spritely finds a file named skins.yml
or gradmaps.yml
(or one of a few similar variants) inside a sprite folder,
it will assume that it is a collection of gradient maps with format
given above, and those will be available for creating recolored images
of that sprite.
Alternatively, you can explicitly specify that Spritely use a different file of Gradient Maps.
- Requires Node.js 14+.
- Only tested on Windows 10.
In a terminal, run npm install --global @bscotch/spritely
In order to correct your sprite subimages, they must be organized into one folder per sprite, each containing the subimages making up that sprite as immediate PNG children.
By default, it is assumed that all subimages of a sprite should have identical dimensions, and errors will be thrown if that isn't true. You can bypass this assumption via the CLI or programmatic use of Spritely.
For example, you might have a sprite called enemy
with three
subimages to create a run cycle. You would save these like this:
enemy/ # Folder representing the sprite
enemy/enemy-idle.png
enemy/enemy-run.png
enemy/enemy-sit.png
You'll likely be using the CLI to run batch operations on your images. It's also likely that you'll want most of your images to be treated one way, while some subset are treated another. This can get annoying, since you'll have to run separate CLI commands, and put images in separate folders, to make that happen.
Alternatively, you can add suffixes to your source image names to ovverride whatever the CLI is doing. This allows you to put all images in one place, use one CLI command to handle the most common case, and then simply add a suffix to the names of those sprites you want to have different treatments.
Suffixes are:
--c
or--crop
: force cropping--nc
or--no-crop
: block cropping--b
or--bleed
: force bleeding--nb
or--no-bleed
: block bleeding
For example, if you had a sprite (folder) named mySprite--c--nb
(force crop, block bleed),
and then ran the CLI command spritely bleed . . .
(see below),
the end result would be an image that was cropped but not bled.
In other words, suffix methods will always be performed or blocked whenever any CLI command is run.
Note that sprites (folders) will be renamed to remove the suffixes, so make sure that won't create naming conflicts.
Run spritely commands by opening up a terminal
(such as Powershell, cmd, Git Bash, bash, etc), typing in
spritely COMMAND ...
, and hitting ENTER.
To find all the commands and options, run spritely -h
. To get
more information about a specific command, run spritely THE-COMMAND -h
.
For example, spritely crop
will run the crop
command, while
spritely crop -h
will show you the help information for the crop
command.
Note that the Current Working Directory generally refers to the folder in which you opened your terminal open.
With the following file organization:
enemy/ # Folder representing the sprite
enemy/enemy-idle.png
enemy/enemy-run.png
enemy/leg/ # A subfolder representing another sprite related to 'enemy'
enemy/leg/leg-stand.png
enemy/leg/leg-walk.png
You could do the following (remember that your files will be permanently changed -- make sure you have backups!):
spritely crop --folder enemy
will cropenemy/enemy-idle.png
andenemy/enemy-run.png
spritely crop -f enemy
is shorthand for the same thingspritely crop --recursive -f enemy
will find all nested folders, treating each as a sprite, so thatenemy/leg/leg-stand.png
andenemy/leg/leg-walk.png
will also be cropped. Use with caution!spritely crop -r -f enemy
is shorthand for the same thingspritely crop -f "C:\User\Me\Desktop\enemy"
provides an absolute path if you are not currently in the parent folder of theenemy
folder.spritely bleed -f enemy
outlines the important parts ofenemy/enemy-idle.png
andenemy/enemy-run.png
with nearly-transparent pixels to improve interpolation for subpixel camera positioning.spritely fix -f enemy
crops and bleeds theenemy
sprite.spritely fix -f enemy --move somewhere/else
moves the sprite tosomewhere/else
after it has been processed. Also works recursively, with path provided to--move
being used as the root directory. Note that old subimages in the target directory are deleted prior to moving the new ones, to ensure that the target directory has only the expected images. This feature is useful for pipelines where the presence/absence of images is used as an indicator for progress through the pipeline, or for export tools that refuse to overwrite existing images.spritely fix -f enemy --move somewhere/else --purge-top-level-folders
will delete top-level folders (immediate children of--folder
) prior to moving changed images. This is useful for ensuring that any sprites deleted from the source also don't appear downstream.spritely fix -f enemy --root-images-are-sprites
causes any images directly in the root folder (enemy
) to be treated as individual sprites, by putting each into their own folder. When used in combination with the--recursive
flag, only the root-level images are treated this way (all others are treated as normal). This is useful for cases where sprites containing only one image are not exported by your drawing software into a folder, but only as a single image.spritely fix -r -f folder/with/all/your/sprites/ --if-match pattern
recursively looks at all the sprites in thefolder/with/all/your/sprites/
and performs the tasks only if the pattern you provided with--if-match
matches the top-level directory. Patterns are case-sensitive and are converted to regex with the JavaScriptnew RegExp()
constructor. If you don't know what that means, don't worry, it'll still behave the way you expect most of the time. For example, with patternhello
you'd matchfolder/with/all/your/sprites/helloworld
andfolder/with/all/your/sprites/ohhello
, but would not matchfolder/with/all/your/sprites/different_top_level/hello
.spritely skin -r -f sprites/to/recolor --gradient-maps-file my-map.yml
applies the gradient maps found inmy-map.yml
to every sprite found insprites/to/recolor
(recursively). Because application of gradient maps causes new sprites to be created, this CLI command has fewer options than the others and should be used with care.
If you want to add Spritely functionality to a Node.js project, you can import it into a Node/Typescript module.
The classes and methods are all documented via Typescript and JSDocs, so you'll be able to figure out your options using the autocomplete features of Typescript/JSDoc-aware code editors like Visual Studio Code.
import {Spritely} from "@bscotch/spritely";
// or, for node/Javascript
const {Spritely} = require("@bscotch/spritely");
async function myPipeline(){
const sprite = new Spritely('path/to/your/sprite/folder');
// use async/await syntax
await sprite.crop();
await sprite.bleed();
await sprite.applyGradientMaps();
// or use .then() syntax
sprite.crop().then(cropped=>cropped.bleed());
}
Pipelines likely require discovering many sprites instead of
only pointing at one specific sprite. Spritely includes a
SpritelyBatch
class for discovering sprite folders and creating
a collection of Spritely instances from them.
import {SpritelyBatch} from "@bscotch/spritely";
const batch = new SpritelyBatch('path/to/your/sprite/storage/root');
// Get a shallow copy of the list of created Spritely instances
const sprites = batch.sprites;