Skip to content

Commit

Permalink
Merge pull request #963 from VladiStep/loadSaveOptimizations
Browse files Browse the repository at this point in the history
QOI textures (de)serialization optimizations.
  • Loading branch information
Grossley committed Jun 16, 2022
2 parents d21ba69 + 0437db6 commit d4630e5
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 51 deletions.
104 changes: 87 additions & 17 deletions UndertaleModLib/Models/UndertaleEmbeddedTexture.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using ICSharpCode.SharpZipLib.BZip2;
using System;
using System.Buffers.Binary;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using UndertaleModLib.Util;

Expand Down Expand Up @@ -116,19 +118,78 @@ public void Dispose()
public class TexData : UndertaleObject, INotifyPropertyChanged, IDisposable
{
private byte[] _textureBlob;
private static MemoryStream sharedStream;

/// <summary>
/// The image data of the texture.
/// </summary>
public byte[] TextureBlob { get => _textureBlob; set { _textureBlob = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TextureBlob))); } }
public byte[] TextureBlob
{
get => _textureBlob;
set
{
_textureBlob = value;
OnPropertyChanged();
}
}

/// <summary>
/// The width of the texture.
/// In case of an invalid texture data, this will be <c>-1</c>.
/// </summary>
public int Width
{
get
{
if (_textureBlob is null || _textureBlob.Length < 24)
return -1;

ReadOnlySpan<byte> span = _textureBlob.AsSpan();
return BinaryPrimitives.ReadInt32BigEndian(span[16..20]);
}
}
/// <summary>
/// The height of the texture.
/// In case of an invalid texture data, this will be <c>-1</c>.
/// </summary>
public int Height
{
get
{
if (_textureBlob is null || _textureBlob.Length < 24)
return -1;

ReadOnlySpan<byte> span = _textureBlob.AsSpan();
return BinaryPrimitives.ReadInt32BigEndian(span[20..24]);
}
}

/// <inheritdoc />
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

private static readonly byte[] pngHeader = { 137, 80, 78, 71, 13, 10, 26, 10 };
private static readonly byte[] qoiAndBZipHeader = { 50, 122, 111, 113 };
private static readonly byte[] qoiHeader = { 102, 105, 111, 113 };

/// <summary>
/// Frees up <see cref="sharedStream"/> from memory.
/// </summary>
public static void ClearSharedStream()
{
sharedStream?.Dispose();
sharedStream = null;
}

/// <summary>
/// Initializes <see cref="sharedStream"/> with a specified initial size.
/// </summary>
/// <param name="size">Initial size of <see cref="sharedStream"/> in bytes</param>
public static void InitSharedStream(int size) => sharedStream = new(size);

/// <inheritdoc />
public void Serialize(UndertaleWriter writer)
{
Expand All @@ -144,16 +205,16 @@ public void Serialize(UndertaleWriter writer)
writer.Write((short)bmp.Height);
byte[] data = QoiConverter.GetArrayFromImage(bmp, writer.undertaleData.GM2022_3 ? 0 : 4);
using MemoryStream input = new MemoryStream(data);
using MemoryStream output = new MemoryStream(1024);
BZip2.Compress(input, output, false, 9);
output.Seek(0, SeekOrigin.Begin);
writer.Write(output);
if (sharedStream.Length != 0)
sharedStream.Seek(0, SeekOrigin.Begin);
BZip2.Compress(input, sharedStream, false, 9);
writer.Write(sharedStream.GetBuffer().AsSpan()[..(int)sharedStream.Position]);
}
else
{
// Encode the PNG data back to QOI
writer.Write(QoiConverter.GetSpanFromImage(TextureWorker.GetImageFromByteArray(TextureBlob),
writer.undertaleData.GM2022_3 ? 0 : 4));
using Bitmap bmp = TextureWorker.GetImageFromByteArray(TextureBlob);
writer.Write(QoiConverter.GetSpanFromImage(bmp, writer.undertaleData.GM2022_3 ? 0 : 4));
}
}
else
Expand All @@ -163,6 +224,8 @@ public void Serialize(UndertaleWriter writer)
/// <inheritdoc />
public void Unserialize(UndertaleReader reader)
{
sharedStream ??= new();

uint startAddress = reader.Position;

byte[] header = reader.ReadBytes(8);
Expand All @@ -179,13 +242,16 @@ public void Unserialize(UndertaleReader reader)
reader.Position += 8;

// Need to fully decompress and convert the QOI data to PNG for compatibility purposes (at least for now)
using MemoryStream result = new MemoryStream(1024);
BZip2.Decompress(reader.Stream, result, false);
result.Seek(0, SeekOrigin.Begin);
using Bitmap bmp = QoiConverter.GetImageFromSpan(result.GetBuffer());
using MemoryStream final = new MemoryStream();
bmp.Save(final, ImageFormat.Png);
TextureBlob = final.ToArray();
if (sharedStream.Length != 0)
sharedStream.Seek(0, SeekOrigin.Begin);
BZip2.Decompress(reader.Stream, sharedStream, false);
ReadOnlySpan<byte> decompressed = sharedStream.GetBuffer().AsSpan()[..(int)sharedStream.Position];
using Bitmap bmp = QoiConverter.GetImageFromSpan(decompressed);
sharedStream.Seek(0, SeekOrigin.Begin);
bmp.Save(sharedStream, ImageFormat.Png);
TextureBlob = new byte[(int)sharedStream.Position];
sharedStream.Seek(0, SeekOrigin.Begin);
sharedStream.Read(TextureBlob, 0, TextureBlob.Length);
return;
}
else if (header.Take(4).SequenceEqual(qoiHeader))
Expand All @@ -195,9 +261,12 @@ public void Unserialize(UndertaleReader reader)

// Need to convert the QOI data to PNG for compatibility purposes (at least for now)
using Bitmap bmp = QoiConverter.GetImageFromStream(reader.Stream);
using MemoryStream final = new MemoryStream();
bmp.Save(final, ImageFormat.Png);
TextureBlob = final.ToArray();
if (sharedStream.Length != 0)
sharedStream.Seek(0, SeekOrigin.Begin);
bmp.Save(sharedStream, ImageFormat.Png);
TextureBlob = new byte[(int)sharedStream.Position];
sharedStream.Seek(0, SeekOrigin.Begin);
sharedStream.Read(TextureBlob, 0, TextureBlob.Length);
return;
}
else
Expand Down Expand Up @@ -228,6 +297,7 @@ public void Dispose()
GC.SuppressFinalize(this);

_textureBlob = null;
ClearSharedStream();
}
}
}
1 change: 1 addition & 0 deletions UndertaleModLib/Models/UndertaleTexturePageItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ public void ReplaceTexture(Image replaceImage, bool disposeImage = true)
g.Dispose();

TexturePage.TextureData.TextureBlob = TextureWorker.GetImageBytes(embImage);

worker.Cleanup();
}

Expand Down
14 changes: 14 additions & 0 deletions UndertaleModLib/UndertaleChunks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,20 @@ internal override void SerializeChunk(UndertaleWriter writer)
base.SerializeChunk(writer);

// texture blobs
if (List.Count > 0)
{
// Compressed size can't be bigger than maximum decompressed size
int maxSize = List.Select(x => x.TextureData.TextureBlob?.Length ?? 0).Max();
UndertaleEmbeddedTexture.TexData.InitSharedStream(maxSize);

if (writer.undertaleData.UseQoiFormat)
{
// Calculate maximum size of QOI converter buffer
maxSize = List.Select(x => x.TextureData.Width * x.TextureData.Height).Max()
* QoiConverter.MaxChunkSize + QoiConverter.HeaderSize + (writer.undertaleData.GM2022_3 ? 0 : 4);
QoiConverter.InitSharedBuffer(maxSize);
}
}
foreach (UndertaleEmbeddedTexture obj in List)
obj.SerializeBlob(writer);

Expand Down
92 changes: 58 additions & 34 deletions UndertaleModLib/Util/QoiConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ namespace UndertaleModLib.Util
/// <remarks>Ported over from DogScepter's QOI converter at <see href="https://github.com/colinator27/dog-scepter/"/>.</remarks>
public static class QoiConverter
{
public const int MaxChunkSize = 5; // according to the QOI spec: https://qoiformat.org/qoi-specification.pdf
public const int HeaderSize = 12;

private const byte QOI_INDEX = 0x00;
private const byte QOI_RUN_8 = 0x40;
private const byte QOI_RUN_16 = 0x60;
Expand All @@ -24,6 +27,24 @@ public static class QoiConverter
private const byte QOI_MASK_3 = 0xe0;
private const byte QOI_MASK_4 = 0xf0;

private static byte[] sharedBuffer;
private static bool isBufferEmpty = true;

/// <summary>
/// Frees up <see cref="sharedBuffer"/> from memory.
/// </summary>
public static void ClearSharedBuffer() => sharedBuffer = null;

/// <summary>
/// Initializes <see cref="sharedBuffer"/> with a specified size.
/// </summary>
/// <param name="size">Size of <see cref="sharedBuffer"/> in bytes</param>
public static void InitSharedBuffer(int size)
{
isBufferEmpty = true;
sharedBuffer = new byte[size];
}

/// <summary>
/// Creates a <see cref="Bitmap"/> from a <see cref="Stream"/>.
/// </summary>
Expand Down Expand Up @@ -180,19 +201,20 @@ public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan<byte> bytes, out int l
/// <param name="padding">The amount of bytes of padding that should be used.</param>
/// <returns>A QOI Image as a byte array.</returns>
/// <exception cref="Exception">If there was an error with stride width.</exception>
public unsafe static Span<byte> GetSpanFromImage(Bitmap bmp, int padding = 4) {
const int maxChunkSize = 5; // according to the QOI spec: https://qoiformat.org/qoi-specification.pdf
const int headerSize = 12;
byte[] res = new byte[bmp.Width * bmp.Height * maxChunkSize + headerSize + padding]; // default capacity
public unsafe static Span<byte> GetSpanFromImage(Bitmap bmp, int padding = 4)
{
if (!isBufferEmpty)
Array.Clear(sharedBuffer);

// Little-endian QOIF image magic
res[0] = (byte)'f';
res[1] = (byte)'i';
res[2] = (byte)'o';
res[3] = (byte)'q';
res[4] = (byte)(bmp.Width & 0xff);
res[5] = (byte)((bmp.Width >> 8) & 0xff);
res[6] = (byte)(bmp.Height & 0xff);
res[7] = (byte)((bmp.Height >> 8) & 0xff);
sharedBuffer[0] = (byte)'f';
sharedBuffer[1] = (byte)'i';
sharedBuffer[2] = (byte)'o';
sharedBuffer[3] = (byte)'q';
sharedBuffer[4] = (byte)(bmp.Width & 0xff);
sharedBuffer[5] = (byte)((bmp.Width >> 8) & 0xff);
sharedBuffer[6] = (byte)(bmp.Height & 0xff);
sharedBuffer[7] = (byte)((bmp.Height >> 8) & 0xff);

BitmapData data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
if (data.Stride != bmp.Width * 4)
Expand All @@ -201,7 +223,7 @@ public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan<byte> bytes, out int l
byte* bmpPtr = (byte*)data.Scan0;
byte* bmpEnd = bmpPtr + (4 * bmp.Width * bmp.Height);

int resPos = headerSize;
int resPos = HeaderSize;
byte r = 0, g = 0, b = 0, a = 255;
int run = 0;
int v = 0, vPrev = 0xff;
Expand All @@ -221,13 +243,13 @@ public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan<byte> bytes, out int l
if (run < 33)
{
run -= 1;
res[resPos++] = (byte)(QOI_RUN_8 | run);
sharedBuffer[resPos++] = (byte)(QOI_RUN_8 | run);
}
else
{
run -= 33;
res[resPos++] = (byte)(QOI_RUN_16 | (run >> 8));
res[resPos++] = (byte)run;
sharedBuffer[resPos++] = (byte)(QOI_RUN_16 | (run >> 8));
sharedBuffer[resPos++] = (byte)run;
}
run = 0;
}
Expand All @@ -236,7 +258,7 @@ public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan<byte> bytes, out int l
int indexPos = (r ^ g ^ b ^ a) & 63;
if (index[indexPos] == v)
{
res[resPos++] = (byte)(QOI_INDEX | indexPos);
sharedBuffer[resPos++] = (byte)(QOI_INDEX | indexPos);
}
else
{
Expand All @@ -256,33 +278,33 @@ public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan<byte> bytes, out int l
vg > -3 && vg < 2 &&
vb > -3 && vb < 2)
{
res[resPos++] = (byte)(QOI_DIFF_8 | (vr << 4 & 48) | (vg << 2 & 12) | (vb & 3));
sharedBuffer[resPos++] = (byte)(QOI_DIFF_8 | (vr << 4 & 48) | (vg << 2 & 12) | (vb & 3));
}
else if (va == 0 &&
vg > -9 && vg < 8 &&
vb > -9 && vb < 8)
{
res[resPos++] = (byte)(QOI_DIFF_16 | (vr & 31));
res[resPos++] = (byte)((vg << 4 & 240) | (vb & 15));
sharedBuffer[resPos++] = (byte)(QOI_DIFF_16 | (vr & 31));
sharedBuffer[resPos++] = (byte)((vg << 4 & 240) | (vb & 15));
}
else
{
res[resPos++] = (byte)(QOI_DIFF_24 | (vr >> 1 & 15));
res[resPos++] = (byte)((vr << 7 & 128) | (vg << 2 & 124) | (vb >> 3 & 3));
res[resPos++] = (byte)((vb << 5 & 224) | (va & 31));
sharedBuffer[resPos++] = (byte)(QOI_DIFF_24 | (vr >> 1 & 15));
sharedBuffer[resPos++] = (byte)((vr << 7 & 128) | (vg << 2 & 124) | (vb >> 3 & 3));
sharedBuffer[resPos++] = (byte)((vb << 5 & 224) | (va & 31));
}
}
else
{
res[resPos++] = (byte)(QOI_COLOR | (vr != 0 ? 8 : 0) | (vg != 0 ? 4 : 0) | (vb != 0 ? 2 : 0) | (va != 0 ? 1 : 0));
sharedBuffer[resPos++] = (byte)(QOI_COLOR | (vr != 0 ? 8 : 0) | (vg != 0 ? 4 : 0) | (vb != 0 ? 2 : 0) | (va != 0 ? 1 : 0));
if (vr != 0)
res[resPos++] = r;
sharedBuffer[resPos++] = r;
if (vg != 0)
res[resPos++] = g;
sharedBuffer[resPos++] = g;
if (vb != 0)
res[resPos++] = b;
sharedBuffer[resPos++] = b;
if (va != 0)
res[resPos++] = a;
sharedBuffer[resPos++] = a;
}
}
}
Expand All @@ -297,13 +319,15 @@ public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan<byte> bytes, out int l
resPos += padding;

// Write final length
int length = resPos - headerSize;
res[8] = (byte)(length & 0xff);
res[9] = (byte)((length >> 8) & 0xff);
res[10] = (byte)((length >> 16) & 0xff);
res[11] = (byte)((length >> 24) & 0xff);
int length = resPos - HeaderSize;
sharedBuffer[8] = (byte)(length & 0xff);
sharedBuffer[9] = (byte)((length >> 8) & 0xff);
sharedBuffer[10] = (byte)((length >> 16) & 0xff);
sharedBuffer[11] = (byte)((length >> 24) & 0xff);

isBufferEmpty = false;

return res.AsSpan()[..resPos];
return sharedBuffer.AsSpan()[..resPos];
}
}
}
6 changes: 6 additions & 0 deletions UndertaleModTool/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,8 @@ private async Task LoadFile(string filename, bool preventClose = false)
FileMessageEvent?.Invoke(message);
});
}
UndertaleEmbeddedTexture.TexData.ClearSharedStream();
}
catch (Exception e)
{
Expand Down Expand Up @@ -1052,6 +1054,10 @@ private async Task SaveFile(string filename, bool suppressDebug = false)
});
}
UndertaleEmbeddedTexture.TexData.ClearSharedStream();
if (Data.UseQoiFormat)
QoiConverter.ClearSharedBuffer();
if (debugMode != DebugDataDialog.DebugDataMode.NoDebug)
{
FileMessageEvent?.Invoke("Generating debugger data...");
Expand Down

0 comments on commit d4630e5

Please sign in to comment.