From 4708385f696d2e47d24e795f210752f4b0224bff Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 25 May 2022 22:46:51 -0400 Subject: add IRawTextureData asset type --- src/SMAPI/Framework/Content/AssetDataForImage.cs | 173 ++++++++++++++++------- src/SMAPI/Framework/Content/RawTextureData.cs | 10 ++ 2 files changed, 131 insertions(+), 52 deletions(-) create mode 100644 src/SMAPI/Framework/Content/RawTextureData.cs (limited to 'src/SMAPI/Framework/Content') diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 97729c95..3393b22f 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StardewValley; @@ -29,86 +30,154 @@ namespace StardewModdingAPI.Framework.Content : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } /// - public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) + public void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) { - // get texture + this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height); + + // validate source data if (source == null) - throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture."); - Texture2D target = this.Data; + throw new ArgumentNullException(nameof(source), "Can't patch from null source data."); - // get areas - sourceArea ??= new Rectangle(0, 0, source.Width, source.Height); - targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height)); + // get the pixels for the source area + Color[] sourceData; + { + int areaX = sourceArea.Value.X; + int areaY = sourceArea.Value.Y; + int areaWidth = sourceArea.Value.Width; + int areaHeight = sourceArea.Value.Height; - // validate + if (areaX == 0 && areaY == 0 && areaWidth == source.Width && areaHeight == source.Height) + sourceData = source.Data; + else + { + sourceData = new Color[areaWidth * areaHeight]; + int i = 0; + for (int y = areaY, maxY = areaY + areaHeight - 1; y <= maxY; y++) + { + for (int x = areaX, maxX = areaX + areaWidth - 1; x <= maxX; x++) + { + int targetIndex = (y * source.Width) + x; + sourceData[i++] = source.Data[targetIndex]; + } + } + } + } + + // apply + this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode); + } + + /// + public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) + { + this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height); + + // validate source texture + if (source == null) + throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture."); if (!source.Bounds.Contains(sourceArea.Value)) throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); - if (!target.Bounds.Contains(targetArea.Value)) - throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture."); - if (sourceArea.Value.Size != targetArea.Value.Size) - throw new InvalidOperationException("The source and target areas must be the same size."); // get source data int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; Color[] sourceData = GC.AllocateUninitializedArray(pixelCount); source.GetData(0, sourceArea, sourceData, 0, pixelCount); - // merge data in overlay mode + // apply + this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode); + } + + /// + public bool ExtendImage(int minWidth, int minHeight) + { + if (this.Data.Width >= minWidth && this.Data.Height >= minHeight) + return false; + + Texture2D original = this.Data; + Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); + this.ReplaceWith(texture); + this.PatchImage(original); + return true; + } + + + /********* + ** Private methods + *********/ + /// Get the bounds for an image patch. + /// The source area to set if needed. + /// The target area to set if needed. + /// The width of the full source image. + /// The height of the full source image. + private void GetPatchBounds([NotNull] ref Rectangle? sourceArea, [NotNull] ref Rectangle? targetArea, int sourceWidth, int sourceHeight) + { + sourceArea ??= new Rectangle(0, 0, sourceWidth, sourceHeight); + targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, this.Data.Width), Math.Min(sourceArea.Value.Height, this.Data.Height)); + } + + /// Overwrite part of the image. + /// The image data to patch into the content. + /// The pixel width of the source image. + /// The pixel height of the source image. + /// The part of the to copy (or null to take the whole texture). This must be within the bounds of the texture. + /// The part of the content to patch (or null to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet. + /// Indicates how an image should be patched. + /// One of the arguments is null. + /// The is outside the bounds of the spritesheet. + /// The content being read isn't an image. + private void PatchImageImpl(Color[] sourceData, int sourceWidth, int sourceHeight, Rectangle sourceArea, Rectangle targetArea, PatchMode patchMode) + { + // get texture + Texture2D target = this.Data; + int pixelCount = sourceArea.Width * sourceArea.Height; + + // validate + if (sourceArea.X < 0 || sourceArea.Y < 0 || sourceArea.Right > sourceWidth || sourceArea.Bottom > sourceHeight) + throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); + if (!target.Bounds.Contains(targetArea)) + throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture."); + if (sourceArea.Size != targetArea.Size) + throw new InvalidOperationException("The source and target areas must be the same size."); + + // merge data if (patchMode == PatchMode.Overlay) { // get target data - Color[] targetData = GC.AllocateUninitializedArray(pixelCount); - target.GetData(0, targetArea, targetData, 0, pixelCount); + Color[] mergedData = GC.AllocateUninitializedArray(pixelCount); + target.GetData(0, targetArea, mergedData, 0, pixelCount); // merge pixels - for (int i = 0; i < sourceData.Length; i++) + for (int i = 0; i < pixelCount; i++) { Color above = sourceData[i]; - Color below = targetData[i]; + Color below = mergedData[i]; // shortcut transparency if (above.A < MinOpacity) - { - sourceData[i] = below; continue; - } if (below.A < MinOpacity) - { - sourceData[i] = above; - continue; - } + mergedData[i] = above; // merge pixels - // This performs a conventional alpha blend for the pixels, which are already - // premultiplied by the content pipeline. The formula is derived from - // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. - // Note: don't use named arguments here since they're different between - // Linux/macOS and Windows. - float alphaBelow = 1 - (above.A / 255f); - sourceData[i] = new Color( - (int)(above.R + (below.R * alphaBelow)), // r - (int)(above.G + (below.G * alphaBelow)), // g - (int)(above.B + (below.B * alphaBelow)), // b - Math.Max(above.A, below.A) // a - ); + else + { + // This performs a conventional alpha blend for the pixels, which are already + // premultiplied by the content pipeline. The formula is derived from + // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. + float alphaBelow = 1 - (above.A / 255f); + mergedData[i] = new Color( + r: (int)(above.R + (below.R * alphaBelow)), + g: (int)(above.G + (below.G * alphaBelow)), + b: (int)(above.B + (below.B * alphaBelow)), + alpha: Math.Max(above.A, below.A) + ); + } } - } - - // patch target texture - target.SetData(0, targetArea, sourceData, 0, pixelCount); - } - - /// - public bool ExtendImage(int minWidth, int minHeight) - { - if (this.Data.Width >= minWidth && this.Data.Height >= minHeight) - return false; - Texture2D original = this.Data; - Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); - this.ReplaceWith(texture); - this.PatchImage(original); - return true; + target.SetData(0, targetArea, mergedData, 0, pixelCount); + } + else + target.SetData(0, targetArea, sourceData, 0, pixelCount); } } } diff --git a/src/SMAPI/Framework/Content/RawTextureData.cs b/src/SMAPI/Framework/Content/RawTextureData.cs new file mode 100644 index 00000000..4a0835b0 --- /dev/null +++ b/src/SMAPI/Framework/Content/RawTextureData.cs @@ -0,0 +1,10 @@ +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI.Framework.Content +{ + /// The raw data for an image read from the filesystem. + /// The image width. + /// The image height. + /// The loaded image data. + internal record RawTextureData(int Width, int Height, Color[] Data) : IRawTextureData; +} -- cgit From 03897776e08cb0703c9763e3b5dcc81d85f277f9 Mon Sep 17 00:00:00 2001 From: Ameisen <14104310+ameisen@users.noreply.github.com> Date: Wed, 1 Jun 2022 19:39:47 -0500 Subject: Cleaning up and optimizing `ContentCache.cs` --- src/SMAPI/Framework/Content/ContentCache.cs | 31 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) (limited to 'src/SMAPI/Framework/Content') diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 736ee5da..959d4fb3 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.Content ** Fields *********/ /// The underlying asset cache. - private readonly IDictionary Cache; + private readonly Dictionary Cache; /********* @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Framework.Content } /// The current cache keys. - public IEnumerable Keys => this.Cache.Keys; + public Dictionary.KeyCollection Keys => this.Cache.Keys; /********* @@ -89,33 +89,40 @@ namespace StardewModdingAPI.Framework.Content /// Returns the removed key (if any). public bool Remove(string key, bool dispose) { - // get entry - if (!this.Cache.TryGetValue(key, out object? value)) + // remove and get entry + if (!this.Cache.Remove(key, out object? value)) return false; // dispose & remove entry if (dispose && value is IDisposable disposable) disposable.Dispose(); - return this.Cache.Remove(key); + return true; } - /// Purge matched assets from the cache. + /// Purge assets matching from the cache. /// Matches the asset keys to invalidate. - /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. - /// Returns the removed keys (if any). + /// Whether to dispose invalidated assets. This should only be when they're being invalidated as part of a , to avoid crashing the game. + /// Returns any removed keys. public IEnumerable Remove(Func predicate, bool dispose) { List removed = new List(); - foreach (string key in this.Cache.Keys.ToArray()) + foreach ((string key, object value) in this.Cache) { - if (predicate(key, this.Cache[key])) + if (predicate(key, value)) { - this.Remove(key, dispose); removed.Add(key); } } - return removed; + + foreach (string key in removed) + { + this.Remove(key, dispose); + } + + // If `removed` is empty, return an empty `Enumerable` instead so that `removed` + // can be quickly collected in Gen0 instead of potentially living longer. + return removed.Count == 0 ? Enumerable.Empty() : removed; } } } -- cgit From 62328e438487a55cb84ee09ef966092572b2252e Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 2 Jun 2022 01:28:04 -0400 Subject: tweak new code, update release notes --- docs/release-notes.md | 1 + src/SMAPI/Framework/Content/ContentCache.cs | 12 ++++-------- 2 files changed, 5 insertions(+), 8 deletions(-) (limited to 'src/SMAPI/Framework/Content') diff --git a/docs/release-notes.md b/docs/release-notes.md index db229ecf..66447b3d 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -6,6 +6,7 @@ See [release highlights](https://www.patreon.com/posts/66986798). * For players: * Optimized mod image file loading. + * Minor optimizations (thanks to Michael Kuklinski / Ameisen!). * For mod authors: * Added a new `IRawTextureData` asset type. _You can now load image files through `helper.ModContent` as `IRawTextureData` instead of `Texture2D`. This provides the image size and raw pixel data, which you can pass into other SMAPI APIs like `asset.AsImage().PatchImage`. This is much more efficient when you don't need a full `Texture2D` instance, since it bypasses the GPU operations needed to create one._ diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 959d4fb3..bf42812b 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -106,23 +106,19 @@ namespace StardewModdingAPI.Framework.Content /// Returns any removed keys. public IEnumerable Remove(Func predicate, bool dispose) { - List removed = new List(); + List removed = new(); foreach ((string key, object value) in this.Cache) { if (predicate(key, value)) - { removed.Add(key); - } } foreach (string key in removed) - { this.Remove(key, dispose); - } - // If `removed` is empty, return an empty `Enumerable` instead so that `removed` - // can be quickly collected in Gen0 instead of potentially living longer. - return removed.Count == 0 ? Enumerable.Empty() : removed; + return removed.Count == 0 + ? Enumerable.Empty() // let GC collect the list in gen0 instead of potentially living longer + : removed; } } } -- cgit