Last active
July 12, 2022 16:46
-
-
Save nathanhoad/bf9ddac2e13a5aaa922182005f3da6e5 to your computer and use it in GitHub Desktop.
Aseprite MonoGame Pipeline Extension
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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; } | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See my comment here for anyone having issues with larger files