Skip to content

Instantly share code, notes, and snippets.

@nathanhoad
Last active July 12, 2022 16:46
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save nathanhoad/bf9ddac2e13a5aaa922182005f3da6e5 to your computer and use it in GitHub Desktop.
Aseprite MonoGame Pipeline Extension
// Very special thanks to Noel Berry
// A lot of this is borrowed from https://gist.github.com/NoelFB/778d190e5d17f1b86ebf39325346fcc5
using System.Collections.Generic;
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content.Pipeline;
using Impulse.Utilities;
namespace ImpulsePipeline.Aseprite
{
public enum Modes
{
Indexed = 1,
Grayscale = 2,
RGBA = 3
}
public class Aseprite
{
public Modes Mode;
public int Width;
public int Height;
public List<AsepriteFrame> Frames;
public List<AsepriteLayer> Layers;
public List<AsepriteTag> Tags;
public List<AsepriteSlice> Slices;
private enum Chunks
{
OldPaletteA = 0x0004,
OldPaletteB = 0x0011,
Layer = 0x2004,
Cel = 0x2005,
CelExtra = 0x2006,
Mask = 0x2016,
Path = 0x2017,
FrameTags = 0x2018,
Palette = 0x2019,
UserData = 0x2020,
Slice = 0x2022
}
private enum CelTypes
{
RawCel = 0,
LinkedCel = 1,
CompressedImage = 2
}
/// <summary>
/// Parses Aseprite files.
/// </summary>
/// <param name="filename">Filename</param>
/// <param name="logger">Logger</param>
public Aseprite(string filename, ContentBuildLogger logger)
{
Frames = new List<AsepriteFrame>();
Layers = new List<AsepriteLayer>();
Tags = new List<AsepriteTag>();
Slices = new List<AsepriteSlice>();
using (var stream = File.OpenRead(filename))
{
var reader = new BinaryReader(stream);
// Helpers for translating the Aseprite file format reference
// See: https://github.com/aseprite/aseprite/blob/master/docs/ase-file-specs.md
byte BYTE() { return reader.ReadByte(); }
ushort WORD() { return reader.ReadUInt16(); }
short SHORT() { return reader.ReadInt16(); }
uint DWORD() { return reader.ReadUInt32(); }
long LONG() { return reader.ReadInt32(); }
string STRING() { return Encoding.UTF8.GetString(BYTES(WORD())); }
byte[] BYTES(int number) { return reader.ReadBytes(number); }
void SEEK(int number) { reader.BaseStream.Position += number; }
int frameCount;
// Consume header
{
DWORD();
// Magic number
var magic = WORD();
if (magic != 0xA5e0)
{
throw new Exception("File doesn't appear to be from Aseprite.");
}
// Basic info
frameCount = WORD();
Width = WORD();
Height = WORD();
Mode = (Modes)(WORD() / 8);
logger.LogMessage($"Cels are {Width}x{Height}");
// Ignore a bunch of stuff
DWORD(); // Flags
WORD(); // Speed (deprecated)
DWORD(); // 0
DWORD(); // 0
BYTE(); // Palette entry
SEEK(3); // Ignore these bytes
WORD(); // Number of colors (0 means 256 for old sprites)
BYTE(); // Pixel width
BYTE(); // Pixel height
SEEK(92); // For Future
}
// Some temporary holders
var colorBuffer = new byte[Width * Height * (int)Mode];
var palette = new Color[256];
IUserData lastUserData = null;
for (int i = 0; i < frameCount; i++)
{
var frame = new AsepriteFrame();
Frames.Add(frame);
long frameStart;
long frameEnd;
int chunkCount;
// Frame header
{
frameStart = reader.BaseStream.Position;
frameEnd = frameStart + DWORD();
WORD(); // Magic number (always 0xF1FA)
chunkCount = WORD();
frame.Duration = WORD() / 1000f;
SEEK(6); // For future (set to zero)
}
for (int j = 0; j < chunkCount; j++)
{
long chunkStart;
long chunkEnd;
Chunks chunkType;
// Chunk header
{
chunkStart = reader.BaseStream.Position;
chunkEnd = chunkStart + DWORD();
chunkType = (Chunks)WORD();
}
// Layer
if (chunkType == Chunks.Layer)
{
var layer = new AsepriteLayer();
layer.Flag = (AsepriteLayer.Flags)WORD();
layer.Type = (AsepriteLayer.Types)WORD();
layer.ChildLevel = WORD();
WORD(); // width
WORD(); // height
layer.BlendMode = (AsepriteLayer.BlendModes)WORD();
layer.Opacity = BYTE() / 255f;
SEEK(3);
layer.Name = STRING();
lastUserData = layer;
Layers.Add(layer);
}
// Cel
else if (chunkType == Chunks.Cel)
{
var cel = new AsepriteCel();
var layerIndex = WORD();
cel.Layer = Layers[layerIndex]; // Layer is row (Frame is column)
cel.X = SHORT();
cel.Y = SHORT();
cel.Opacity = BYTE() / 255f;
var celType = (CelTypes)WORD();
SEEK(7);
if (celType == CelTypes.RawCel || celType == CelTypes.CompressedImage)
{
cel.Width = WORD();
cel.Height = WORD();
var byteCount = cel.Width * cel.Height * (int)Mode;
if (celType == CelTypes.RawCel)
{
reader.Read(colorBuffer, 0, byteCount);
}
else
{
SEEK(2);
var deflate = new DeflateStream(reader.BaseStream, CompressionMode.Decompress);
deflate.Read(colorBuffer, 0, byteCount);
}
cel.Pixels = new Color[cel.Width * cel.Height];
ConvertBytesToPixels(colorBuffer, cel.Pixels, palette);
}
else if (celType == CelTypes.LinkedCel)
{
var targetFrame = WORD(); // Frame position to link with
// Grab the cel from a previous frame
var targetCel = Frames[targetFrame].Cels.Where(c => c.Layer == Layers[layerIndex]).First();
cel.Width = targetCel.Width;
cel.Height = targetCel.Height;
cel.Pixels = targetCel.Pixels;
}
lastUserData = cel;
frame.Cels.Add(cel);
}
// Palette
else if (chunkType == Chunks.Palette)
{
var size = DWORD();
var start = DWORD();
var end = DWORD();
SEEK(8);
for (int c = 0; c < (end - start); c++)
{
var hasName = Calc.IsBitSet(WORD(), 0);
palette[start + c] = Color.FromNonPremultiplied(BYTE(), BYTE(), BYTE(), BYTE());
if (hasName)
{
STRING(); // Color name
}
}
}
// User data
else if (chunkType == Chunks.UserData)
{
if (lastUserData != null)
{
var flags = DWORD();
if (Calc.IsBitSet(flags, 0))
{
lastUserData.UserDataText = STRING();
}
else if (Calc.IsBitSet(flags, 1))
{
lastUserData.UserDataColor = Color.FromNonPremultiplied(BYTE(), BYTE(), BYTE(), BYTE());
}
}
}
// Tag (animation reference)
else if (chunkType == Chunks.FrameTags)
{
var tagsCount = WORD();
SEEK(8);
for (int t = 0; t < tagsCount; t++)
{
var tag = new AsepriteTag();
tag.From = WORD();
tag.To = WORD();
tag.LoopDirection = (AsepriteTag.LoopDirections)BYTE();
SEEK(8);
tag.Color = Color.FromNonPremultiplied(BYTE(), BYTE(), BYTE(), 255);
SEEK(1);
tag.Name = STRING();
Tags.Add(tag);
}
}
// Slice
else if (chunkType == Chunks.Slice)
{
var slicesCount = DWORD();
var flags = DWORD();
DWORD();
var name = STRING();
for (int s = 0; s < slicesCount; s++)
{
var slice = new AsepriteSlice();
slice.Name = name;
slice.Frame = (int)DWORD();
slice.OriginX = (int)LONG();
slice.OriginY = (int)LONG();
slice.Width = (int)DWORD();
slice.Height = (int)DWORD();
// 9 slice
if (Calc.IsBitSet(flags, 0))
{
LONG(); // Center X position (relative to slice bounds)
LONG(); // Center Y position
DWORD(); // Center width
DWORD(); // Center height
}
// Pivot
else if (Calc.IsBitSet(flags, 1))
{
slice.Pivot = new Point((int)DWORD(), (int)DWORD());
}
lastUserData = slice;
Slices.Add(slice);
}
}
reader.BaseStream.Position = chunkEnd;
}
reader.BaseStream.Position = frameEnd;
}
}
// Log out what we found
logger.LogMessage("Layers:");
foreach (var layer in Layers)
{
logger.LogMessage($"\t{layer.Name}");
}
logger.LogMessage("Animations:");
foreach (var animation in Tags)
{
if (animation.To == animation.From)
{
logger.LogMessage($"\t{animation.Name} => {animation.From + 1}");
}
else
{
logger.LogMessage($"\t{animation.Name} => {animation.From + 1} - {animation.To + 1}");
}
}
}
private void ConvertBytesToPixels(byte[] bytes, Color[] pixels, Color[] palette)
{
int length = pixels.Length;
if (Mode == Modes.RGBA)
{
for (int pixel = 0, b = 0; pixel < length; pixel++, b += 4)
{
pixels[pixel].R = (byte)(bytes[b + 0] * bytes[b + 3] / 255);
pixels[pixel].G = (byte)(bytes[b + 1] * bytes[b + 3] / 255);
pixels[pixel].B = (byte)(bytes[b + 2] * bytes[b + 3] / 255);
pixels[pixel].A = bytes[b + 3];
}
}
else if (Mode == Modes.Grayscale)
{
for (int pixel = 0, b = 0; pixel < length; pixel++, b += 2)
{
pixels[pixel].R = pixels[pixel].G = pixels[pixel].B = (byte)(bytes[b + 0] * bytes[b + 1] / 255);
pixels[pixel].A = bytes[b + 1];
}
}
else if (Mode == Modes.Indexed)
{
for (int pixel = 0, paletteIndex = 0; pixel < length; pixel++, paletteIndex += 1)
{
pixels[pixel] = palette[paletteIndex];
}
}
}
}
// UserData are extended chunks that get attached
// to other chunks
public interface IUserData
{
string UserDataText { get; set; }
Color UserDataColor { get; set; }
}
// A layer stores just the meta info for a row
// of cels
public class AsepriteLayer : IUserData
{
[Flags]
public enum Flags
{
Visible = 1,
Editable = 2,
LockMovement = 4,
Background = 8,
PreferLinkedCels = 16,
Collapsed = 32,
Reference = 64
}
public enum Types
{
Normal = 0,
Group = 1
}
public enum BlendModes
{
Normal = 0,
Multiply = 1,
Screen = 2,
Overlay = 3,
Darken = 4,
Lighten = 5,
ColorDodge = 6,
ColorBurn = 7,
HardLight = 8,
SoftLight = 9,
Difference = 10,
Exclusion = 11,
Hue = 12,
Saturation = 13,
Color = 14,
Luminosity = 15,
Addition = 16,
Subtract = 17,
Divide = 18
}
public Flags Flag;
public Types Type;
public string Name;
public float Opacity;
public BlendModes BlendMode;
public int ChildLevel;
string IUserData.UserDataText { get; set; }
Color IUserData.UserDataColor { get; set; }
}
// A frame is a column of cels
public class AsepriteFrame
{
public float Duration;
public List<AsepriteCel> Cels;
public AsepriteFrame()
{
Cels = new List<AsepriteCel>();
}
}
// Tags are animation references
public class AsepriteTag
{
public enum LoopDirections
{
Forward = 0,
Reverse = 1,
PingPong = 2
}
public string Name;
public LoopDirections LoopDirection;
public int From;
public int To;
public Color Color;
}
public struct AsepriteSlice : IUserData
{
public int Frame;
public string Name;
public int OriginX;
public int OriginY;
public int Width;
public int Height;
public Point? Pivot;
string IUserData.UserDataText { get; set; }
Color IUserData.UserDataColor { get; set; }
}
// Cels are just pixel grids
public class AsepriteCel : IUserData
{
public AsepriteLayer Layer;
public Color[] Pixels;
public int X;
public int Y;
public int Width;
public int Height;
public float Opacity;
public string UserDataText { get; set; }
public Color UserDataColor { get; set; }
}
}
using Microsoft.Xna.Framework.Content.Pipeline;
namespace ImpulsePipeline.Aseprite
{
[ContentImporter(".aseprite", ".ase", DefaultProcessor = "AsepriteProcessor", DisplayName = "Aseprite Importer - Impulse")]
public class AsepriteImporter : ContentImporter<Aseprite>
{
public override Aseprite Import(string filename, ContentImporterContext context)
{
return new Aseprite(filename, context.Logger);
}
}
}
using Microsoft.Xna.Framework.Content.Pipeline;
namespace ImpulsePipeline.Aseprite
{
[ContentProcessor(DisplayName = "Aseprite Processor - Impulse")]
public class AsepriteProcessor : ContentProcessor<Aseprite, ProcessedAseprite>
{
public override ProcessedAseprite Process(Aseprite input, ContentProcessorContext context)
{
return new ProcessedAseprite()
{
Logger = context.Logger,
Aseprite = input
};
}
}
public class ProcessedAseprite
{
public ContentBuildLogger Logger;
public Aseprite Aseprite;
}
}
using Impulse.Graphics;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using System.Collections.Generic;
namespace Impulse.Content
{
public class AsepriteReader : ContentTypeReader<Aseprite>
{
protected override Aseprite Read(ContentReader input, Aseprite existingInstance)
{
if (existingInstance != null) return existingInstance;
int width = input.ReadInt32();
int height = input.ReadInt32();
Aseprite aseprite = new Aseprite()
{
Width = width,
Height = height
};
// Layers
int layersCount = input.ReadInt32();
SpriteLayer layer;
List<SpriteLayer> layersByIndex = new List<SpriteLayer>();
for (int i = 0; i < layersCount; i++)
{
layer = new SpriteLayer()
{
Name = input.ReadString(),
Opacity = input.ReadSingle(),
BlendMode = (SpriteLayer.BlendModes)input.ReadInt32(),
IsVisible = input.ReadBoolean()
};
aseprite.Layers.Add(layer.Name, layer);
// Use this for referencing cels down further
layersByIndex.Add(layer);
}
// Frames
int framesCount = input.ReadInt32();
SpriteFrame frame;
int celsCount;
SpriteCel cel;
int celOriginX;
int celOriginY;
int celWidth;
int celHeight;
Color pixel;
int pixelIndex;
int textureWidth = framesCount * width;
int textureHeight = layersCount * height;
Color[] pixelData = new Color[textureWidth * textureHeight];
for (int f = 0; f < framesCount; f++)
{
frame = new SpriteFrame()
{
Duration = input.ReadSingle()
};
// Cels
celsCount = input.ReadInt32();
for (int i = 0; i < celsCount; i++)
{
int layerIndex = input.ReadInt32();
cel = new SpriteCel()
{
Layer = layersByIndex[layerIndex],
ClipRect = new Rectangle(f * width, layerIndex * height, width, height)
};
frame.Cels.Add(cel);
// Get info for the texture
celOriginX = input.ReadInt32();
celOriginY = input.ReadInt32();
celWidth = input.ReadInt32();
celHeight = input.ReadInt32();
for (int celY = celOriginY; celY < celOriginY + celHeight; celY++)
{
for (int celX = celOriginX; celX < celOriginX + celWidth; celX++)
{
pixel = input.ReadColor();
// | x | y
pixelIndex = (f * width) + celX + ((layerIndex * height) + celY) * textureWidth;
pixelData[pixelIndex] = pixel;
}
}
}
aseprite.Frames.Add(frame);
}
// Dump our pixels into the texture
aseprite.Texture = new Texture2D(Engine.Graphics.GraphicsDevice, textureWidth, textureHeight);
aseprite.Texture.SetData(pixelData);
// Animations
int animationsCount = input.ReadInt32();
SpriteAnimation animation;
for (int i = 0; i < animationsCount; i++)
{
animation = new SpriteAnimation()
{
Name = input.ReadString(),
FirstFrame = input.ReadInt32(),
LastFrame = input.ReadInt32()
};
aseprite.Animations.Add(animation.Name, animation);
}
// If no animations were added then just add one
// that covers all frames
if (aseprite.Animations.Count == 0)
{
animation = new SpriteAnimation()
{
Name = "Idle",
FirstFrame = 0,
LastFrame = aseprite.Frames.Count - 1
};
aseprite.Animations.Add(animation.Name, animation);
}
return aseprite;
}
}
}
using System;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;
using Impulse.Content;
namespace ImpulsePipeline.Aseprite
{
[ContentTypeWriter]
class AsepriteWriter : ContentTypeWriter<ProcessedAseprite>
{
/// <summary>
/// Write the Aseprite file. Format is:
///
/// Aseprite Document:
/// Width
/// Height
/// Layers: []
/// Name
/// Opacity
/// BlendMode
/// IsVisible
/// Frames: []
/// Duration
/// Cels: []
/// LayerIndex
/// X
/// Y
/// Width
/// Height
/// Pixels
/// Animations: []
/// Name
/// From
/// To
///
/// </summary>
/// <param name="output">Output.</param>
/// <param name="value">Value.</param>
protected override void Write(ContentWriter output, ProcessedAseprite value)
{
Aseprite aseprite = value.Aseprite;
output.Write(aseprite.Width);
output.Write(aseprite.Height);
// Layers
output.Write(aseprite.Layers.Count);
foreach (var layer in aseprite.Layers)
{
output.Write(layer.Name);
output.Write(layer.Opacity);
output.Write((int)layer.BlendMode);
output.Write(layer.Flag.HasFlag(AsepriteLayer.Flags.Visible));
}
// Frames
output.Write(aseprite.Frames.Count);
foreach (var frame in aseprite.Frames)
{
output.Write(frame.Duration);
output.Write(frame.Cels.Count);
for (int i = 0; i < frame.Cels.Count; i++)
{
var cel = frame.Cels[i];
output.Write(aseprite.Layers.IndexOf(cel.Layer));
output.Write(cel.X);
output.Write(cel.Y);
output.Write(cel.Width);
output.Write(cel.Height);
int size = cel.Width * cel.Height;
for (int p = 0; p < size; p++)
{
output.Write(cel.Pixels[p]);
}
}
}
// Animations
output.Write(aseprite.Tags.Count);
foreach (var animation in aseprite.Tags)
{
output.Write(animation.Name);
output.Write(animation.From);
output.Write(animation.To);
}
}
public override string GetRuntimeType(TargetPlatform targetPlatform)
{
Type type = typeof(AsepriteReader);
string readerType = type.Namespace + ".AsepriteReader, " + type.AssemblyQualifiedName;
return readerType;
}
public override string GetRuntimeReader(TargetPlatform targetPlatform)
{
Type type = typeof(AsepriteReader);
string readerType = type.Namespace + ".AsepriteReader, " + type.Assembly.FullName;
return readerType;
}
}
}
@marpe
Copy link

marpe commented Jul 12, 2022

See my comment here for anyone having issues with larger files

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment