Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite texture handling #1870

Merged
merged 16 commits into from
Aug 22, 2024
Merged

Rewrite texture handling #1870

merged 16 commits into from
Aug 22, 2024

Conversation

colinator27
Copy link
Member

Description

Rewrites all of the core texture code to use a new immutable GMImage class, which can be one of four texture types: PNG, raw BGRA pixel data, QOI, or BZip2 + QOI. This class is used as a replacement to the existing byte[] containing compressed PNG data always.

The major benefit of this is that load/save times on games using non-PNG textures are now much faster (some games are at least 5 seconds faster on either end, on my machine). QOI and/or BZ2 textures are now decoded/decompressed on the fly, rather than during the (de)serialization process.

This also mostly replaces System.Drawing.Common calls with ones to ImageMagick instead. In UndertaleModLib, it's used only for PNG encoding/decoding, but I also rewrote a tiny bit of code on the UI side. This should mean the library code itself is cross-platform. Additionally, I performed some tests and found that exporting PNGs with the new UI code preserves colors with 0 alpha.

Caveats

This breaks pretty much every script that interacts with texture data, but that will be resolved in an upcoming PR.

There's also one further memory optimization that can be made with BZ2 + QOI textures, if we can decompress the first 8 bytes of the BZ2 data only. Essentially, it involves finding the size of the inner QOI data, which can be used to initialize the capacity of the buffer used to decompress the BZ2 data later (when called to do so, on the fly). The performance impact is probably not extreme, though - it would prevent a few reallocations at most. It's something maybe worth revisiting in the future.

Notes

Tested on a number of games, including ones with PNG textures, BZ2 + QOI textures, and pre/post GameMaker 2022.5. I haven't yet tested QOI-only games, but the code should theoretically work with them. Files have loaded/saved byte-for-byte, besides a bug in the FONT chunk that's fixed by the serialization cleanup PR (#1864).

I tested collision mask saving/loading, the room editor, the sprite editor, the embedded texture editor (saving/loading), and the texture page item editor (saving/loading).

On the UI front, instances of BitmapSource are cached for as long as the garbage collector doesn't get rid of them from memory, which can prevent unnecessary image decodes.

Copy link

github-actions bot commented Aug 14, 2024

UndertaleModCli/Program.cs Outdated Show resolved Hide resolved
UndertaleModLib/Models/UndertaleEmbeddedTexture.cs Outdated Show resolved Hide resolved
UndertaleModLib/Models/UndertaleTexturePageItem.cs Outdated Show resolved Hide resolved
UndertaleModTool/MainWindow.xaml.cs Outdated Show resolved Hide resolved
UndertaleModTool/UndertaleModTool.csproj Outdated Show resolved Hide resolved
private static object _textureLoadLock = new();
// 1x1 blank image
private static readonly TexData _placeholderTexture = new() { Image = new GMImage(1, 1) };
private static readonly object _textureLoadLock = new();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

afaik MS recommended to use a System.Threading.Lock for locks. It's only available in c# 13+, cant remember what version utmt is in right now. Probably good idea to a make mental note for this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I don't think it's new enough yet, that's a .NET 9 thing.

};
private static object _textureLoadLock = new();
// 1x1 blank image
private static readonly TexData _placeholderTexture = new() { Image = new GMImage(1, 1) };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason this has to be 1x1 instead of 0x0?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, but this is just what shows in lieu of an unloaded (or failed to load) texture

public bool FormatBZ2 => _image.Format is GMImage.ImageFormat.Bz2Qoi;

/// <summary>
/// If located within a data file, this is the maximum end position of the image data (or start of the next texture blob).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what exactly is "maximum end position" supposed to mean? That makes it sound like a minimum end position or an average end position exists.
Also if -1 means "this is an external texture" this should be noted in the documentation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the "maximum end position" in this context is actually an upper bound on the end position of the image data. Inside a data file, it's either set to the start address of the next texture blob, or the end of the TXTR chunk if none exists. When loading textures, this is then narrowed down to the actual end address (but this field isn't updated).

I think I'm going to actually make this a private field, and just make a setter method for this, since this isn't meant to be relied upon for anything other than deserialization.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still confused by the name. If this is the end position of the image, why not just call it "endOfImagePosition"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to update this with a newer comment in just a moment, hopefully it'll be more clear - but this is the upper bound on the image end position, not the actual image end position.

UndertaleModLib/Models/UndertaleEmbeddedTexture.cs Outdated Show resolved Hide resolved
UndertaleModTool/Converters/UndertaleCachedImageLoader.cs Outdated Show resolved Hide resolved
@@ -101,6 +102,9 @@ public object Selected

public List<Tab> ClosedTabsHistory { get; } = new();

private List<(GMImage, WeakReference<BitmapSource>)> _bitmapSourceLookup { get; } = new();
private object _bitmapSourceLookupLock = new();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see lock

@@ -101,6 +102,9 @@ public object Selected

public List<Tab> ClosedTabsHistory { get; } = new();

private List<(GMImage, WeakReference<BitmapSource>)> _bitmapSourceLookup { get; } = new();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason why this needs to be a list instead of a dictionary?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this was originally a dictionary, but I changed it to a list because every lookup does a full sweep on the weak references anyway. Not sure if there's any better way to do it, though (we could possibly defer the sweep operation).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdym with "every lookup does a full sweep on the references"?
The way I see it is that currently, it always iterates over the full list when you want to get just one item, cause it always invalidated everything.

With a dictionary, you would only check the image asked for, and only invalidate that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, so in this case it's weird, because we also want to clear the "dictionary" of the weak references that are no longer valid (because the GC freed their memory). If you don't do this, it could gradually leak memory, especially if the images are getting replaced by new ones. If we move this clearing process somewhere else, we don't need to do a linear lookup here, but then we have to find another good place to do that "sweep" operation anyway. As it stands, though, I don't think we ever have more than a small handful of textures loaded at once.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, then probably no harm in keeping it as a list and keeping the linearity.

Copy link
Member Author

@colinator27 colinator27 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some responses, but I'll get to updating the code soon to resolve most of the comments.

};
private static object _textureLoadLock = new();
// 1x1 blank image
private static readonly TexData _placeholderTexture = new() { Image = new GMImage(1, 1) };
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, but this is just what shows in lieu of an unloaded (or failed to load) texture

private static object _textureLoadLock = new();
// 1x1 blank image
private static readonly TexData _placeholderTexture = new() { Image = new GMImage(1, 1) };
private static readonly object _textureLoadLock = new();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I don't think it's new enough yet, that's a .NET 9 thing.

public bool FormatBZ2 => _image.Format is GMImage.ImageFormat.Bz2Qoi;

/// <summary>
/// If located within a data file, this is the maximum end position of the image data (or start of the next texture blob).
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the "maximum end position" in this context is actually an upper bound on the end position of the image data. Inside a data file, it's either set to the start address of the next texture blob, or the end of the TXTR chunk if none exists. When loading textures, this is then narrowed down to the actual end address (but this field isn't updated).

I think I'm going to actually make this a private field, and just make a setter method for this, since this isn't meant to be relied upon for anything other than deserialization.

UndertaleModLib/Models/UndertaleEmbeddedTexture.cs Outdated Show resolved Hide resolved
UndertaleModLib/Util/TextureWorker.cs Outdated Show resolved Hide resolved
// Grabbed from https://stackoverflow.com/questions/3801275/how-to-convert-image-to-byte-array/16576471#16576471
public static Bitmap GetImageFromByteArray(byte[] byteArray)
/// <summary>
/// Performs a resize of the given image, if required, using bilinear interpolation. Always returns a new image.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, but for now it's probably fine to leave this as-is. We can add optional parameters later to configure the interpolation, if we want.

UndertaleModLib/Util/TextureWorker.cs Outdated Show resolved Hide resolved
UndertaleModTool/Converters/UndertaleCachedImageLoader.cs Outdated Show resolved Hide resolved
@@ -101,6 +102,9 @@ public object Selected

public List<Tab> ClosedTabsHistory { get; } = new();

private List<(GMImage, WeakReference<BitmapSource>)> _bitmapSourceLookup { get; } = new();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this was originally a dictionary, but I changed it to a list because every lookup does a full sweep on the weak references anyway. Not sure if there's any better way to do it, though (we could possibly defer the sweep operation).

UndertaleModLib/UndertaleChunks.cs Outdated Show resolved Hide resolved
UndertaleModLib/Util/GMImage.cs Outdated Show resolved Hide resolved
UndertaleModLib/Util/GMImage.cs Outdated Show resolved Hide resolved
UndertaleModLib/Util/GMImage.cs Outdated Show resolved Hide resolved
UndertaleModLib/Util/TextureWorker.cs Show resolved Hide resolved
UndertaleModLib/Util/GMImage.cs Outdated Show resolved Hide resolved
UndertaleModLib/Util/GMImage.cs Outdated Show resolved Hide resolved
Copy link
Contributor

@Miepee Miepee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, I'll do a quick test on linux CLI soon:tm: to check whether it works and hten merge.

@Miepee
Copy link
Contributor

Miepee commented Aug 22, 2024

CLI was able to dump all embedded textures, so i'll call this a success!
Gonna be merging this now, fixing scripts is next on the table then :)

@Miepee Miepee merged commit 405f10f into master Aug 22, 2024
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants