diff options
-rw-r--r-- | docs/release-notes.md | 3 | ||||
-rw-r--r-- | src/SMAPI/Framework/Content/AssetDataForImage.cs | 173 | ||||
-rw-r--r-- | src/SMAPI/Framework/Content/RawTextureData.cs | 10 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/GameContentManager.cs | 4 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/ModContentManager.cs | 23 | ||||
-rw-r--r-- | src/SMAPI/IAssetDataForImage.cs | 10 | ||||
-rw-r--r-- | src/SMAPI/IContentHelper.cs | 2 | ||||
-rw-r--r-- | src/SMAPI/IContentPack.cs | 2 | ||||
-rw-r--r-- | src/SMAPI/IModContentHelper.cs | 2 | ||||
-rw-r--r-- | src/SMAPI/IRawTextureData.cs | 17 |
10 files changed, 185 insertions, 61 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md index a1b5222e..b22f4de9 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -5,6 +5,9 @@ * For players: * Added experimental image load rewrite (disabled by default). _If you have many content mods installed, enabling `UseExperimentalImageLoading` in `smapi-internal/config.json` may reduce load times or stutters when they load many image files at once._ +* For mod authors: + * Added specialized `IRawTextureData` asset type. + _When you're only loading a mod file to patch it into an asset, you can now load it using `helper.ModContent.Load<IRawTextureData>(path)`. This reads the image data from disk without initializing a `Texture2D` instance through the GPU. You can then pass this to SMAPI APIs that accept `Texture2D` instances._ * For mod authors: * Fixed map edits which change warps sometimes rebuilding the NPC pathfinding cache unnecessarily, which could cause a noticeable delay for players. 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) { } /// <inheritdoc /> - 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); + } + + /// <inheritdoc /> + 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<Color>(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); + } + + /// <inheritdoc /> + 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 + *********/ + /// <summary>Get the bounds for an image patch.</summary> + /// <param name="sourceArea">The source area to set if needed.</param> + /// <param name="targetArea">The target area to set if needed.</param> + /// <param name="sourceWidth">The width of the full source image.</param> + /// <param name="sourceHeight">The height of the full source image.</param> + 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)); + } + + /// <summary>Overwrite part of the image.</summary> + /// <param name="sourceData">The image data to patch into the content.</param> + /// <param name="sourceWidth">The pixel width of the source image.</param> + /// <param name="sourceHeight">The pixel height of the source image.</param> + /// <param name="sourceArea">The part of the <paramref name="sourceData"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="sourceData"/> texture.</param> + /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> + /// <param name="patchMode">Indicates how an image should be patched.</param> + /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> + /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + 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<Color>(pixelCount); - target.GetData(0, targetArea, targetData, 0, pixelCount); + Color[] mergedData = GC.AllocateUninitializedArray<Color>(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); - } - - /// <inheritdoc /> - 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 +{ + /// <summary>The raw data for an image read from the filesystem.</summary> + /// <param name="Width">The image width.</param> + /// <param name="Height">The image height.</param> + /// <param name="Data">The loaded image data.</param> + internal record RawTextureData(int Width, int Height, Color[] Data) : IRawTextureData; +} diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 4390d472..446f4a67 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -9,6 +9,7 @@ using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Deprecations; +using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; @@ -93,6 +94,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <inheritdoc /> public override T LoadExact<T>(IAssetName assetName, bool useCache) { + if (typeof(IRawTextureData).IsAssignableFrom(typeof(T))) + throw new SContentLoadException(ContentLoadErrorType.Other, $"Can't load {nameof(IRawTextureData)} assets from the game content pipeline. This asset type is only available for mod files."); + // raise first-load callback if (GameContentManager.IsFirstLoad) { diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 055dcc5f..eb4f4555 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -8,6 +8,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using SkiaSharp; +using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Serialization; @@ -188,12 +189,17 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="file">The file to load.</param> private T LoadImageFile<T>(IAssetName assetName, FileInfo file) { - // validate + // validate type + bool asRawData = false; if (typeof(T) != typeof(Texture2D)) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + { + asRawData = typeof(T) == typeof(IRawTextureData); + if (!asRawData) + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}' or '{typeof(IRawTextureData)}'."); + } // load - if (this.UseExperimentalImageLoading) + if (asRawData || this.UseExperimentalImageLoading) { // load raw data using FileStream stream = File.OpenRead(file.FullName); @@ -211,9 +217,14 @@ namespace StardewModdingAPI.Framework.ContentManagers } // create texture - Texture2D texture = new(Game1.graphics.GraphicsDevice, bitmap.Width, bitmap.Height); - texture.SetData(pixels); - return (T)(object)texture; + if (asRawData) + return (T)(object)new RawTextureData(bitmap.Width, bitmap.Height, pixels); + else + { + Texture2D texture = new(Game1.graphics.GraphicsDevice, bitmap.Width, bitmap.Height); + texture.SetData(pixels); + return (T)(object)texture; + } } else { diff --git a/src/SMAPI/IAssetDataForImage.cs b/src/SMAPI/IAssetDataForImage.cs index 6f8a4719..3e5b833d 100644 --- a/src/SMAPI/IAssetDataForImage.cs +++ b/src/SMAPI/IAssetDataForImage.cs @@ -18,6 +18,16 @@ namespace StardewModdingAPI /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace); + + /// <summary>Overwrite part of the image.</summary> + /// <param name="source">The image to patch into the content.</param> + /// <param name="sourceArea">The part of the <paramref name="source"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="source"/> texture.</param> + /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> + /// <param name="patchMode">Indicates how an image should be patched.</param> + /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> + /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace); /// <summary>Extend the image if needed to fit the given size. Note that this is an expensive operation, creates a new texture instance, and that extending a spritesheet horizontally may cause game errors or bugs.</summary> diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs index 2cd0c1fc..7637edf0 100644 --- a/src/SMAPI/IContentHelper.cs +++ b/src/SMAPI/IContentHelper.cs @@ -35,7 +35,7 @@ namespace StardewModdingAPI ** Public methods *********/ /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> - /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, <see cref="IRawTextureData"/> (for mod content only), and data structures; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> /// <param name="source">Where to search for a matching content asset.</param> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs index 1215fe0b..73b1a860 100644 --- a/src/SMAPI/IContentPack.cs +++ b/src/SMAPI/IContentPack.cs @@ -48,7 +48,7 @@ namespace StardewModdingAPI where TModel : class; /// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> - /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, <see cref="IRawTextureData"/>, and data structures; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The relative file path within the content pack (case-insensitive).</param> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> diff --git a/src/SMAPI/IModContentHelper.cs b/src/SMAPI/IModContentHelper.cs index f1f6ce94..1e2d82a8 100644 --- a/src/SMAPI/IModContentHelper.cs +++ b/src/SMAPI/IModContentHelper.cs @@ -12,7 +12,7 @@ namespace StardewModdingAPI ** Public methods *********/ /// <summary>Load content from the mod folder and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> - /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, <see cref="IRawTextureData"/>, and data structures; other types may be supported by the game's content pipeline.</typeparam> /// <param name="relativePath">The local path to a content file relative to the mod folder.</param> /// <exception cref="ArgumentException">The <paramref name="relativePath"/> is empty or contains invalid characters.</exception> /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> diff --git a/src/SMAPI/IRawTextureData.cs b/src/SMAPI/IRawTextureData.cs new file mode 100644 index 00000000..a4da52f3 --- /dev/null +++ b/src/SMAPI/IRawTextureData.cs @@ -0,0 +1,17 @@ +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI +{ + /// <summary>The raw data for an image read from the filesystem.</summary> + public interface IRawTextureData + { + /// <summary>The image width.</summary> + int Width { get; } + + /// <summary>The image height.</summary> + int Height { get; } + + /// <summary>The loaded image data.</summary> + Color[] Data { get; } + } +} |