-
Notifications
You must be signed in to change notification settings - Fork 217
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
Rewrite texture handling #1870
Conversation
Initial work, most scripts involving textures will no longer work
Download the artifacts for this pull request here: GUI:
CLI: |
UndertaleModTool/Editors/UndertaleEmbeddedTextureEditor.xaml.cs
Outdated
Show resolved
Hide resolved
UndertaleModTool/Editors/UndertaleEmbeddedTextureEditor.xaml.cs
Outdated
Show resolved
Hide resolved
UndertaleModTool/Editors/UndertaleEmbeddedTextureEditor.xaml.cs
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) }; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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"?
There was a problem hiding this comment.
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.
@@ -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(); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this 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) }; |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
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.
// 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. |
There was a problem hiding this comment.
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.
@@ -101,6 +102,9 @@ public object Selected | |||
|
|||
public List<Tab> ClosedTabsHistory { get; } = new(); | |||
|
|||
private List<(GMImage, WeakReference<BitmapSource>)> _bitmapSourceLookup { get; } = new(); |
There was a problem hiding this comment.
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).
UndertaleModTool/Editors/UndertaleTexturePageItemEditor.xaml.cs
Outdated
Show resolved
Hide resolved
UndertaleModTool/Editors/UndertaleTexturePageItemEditor.xaml.cs
Outdated
Show resolved
Hide resolved
There was a problem hiding this 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.
CLI was able to dump all embedded textures, so i'll call this a success! |
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 existingbyte[]
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 toImageMagick
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.