add IRawTextureData asset type

This commit is contained in:
Jesse Plamondon-Willard 2022-05-25 22:46:51 -04:00
parent a546fd113f
commit 4708385f69
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
10 changed files with 188 additions and 64 deletions

View File

@ -5,6 +5,9 @@
* For players: * For players:
* Added experimental image load rewrite (disabled by default). * 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._ _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: * For mod authors:
* Fixed map edits which change warps sometimes rebuilding the NPC pathfinding cache unnecessarily, which could cause a noticeable delay for players. * Fixed map edits which change warps sometimes rebuilding the NPC pathfinding cache unnecessarily, which could cause a noticeable delay for players.

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using StardewValley; using StardewValley;
@ -28,74 +29,62 @@ namespace StardewModdingAPI.Framework.Content
public AssetDataForImage(string? locale, IAssetName assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) public AssetDataForImage(string? locale, IAssetName assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced)
: base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
/// <inheritdoc />
public void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
{
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 null source data.");
// 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;
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 /> /// <inheritdoc />
public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) public void PatchImage(Texture2D 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 texture
if (source == null) if (source == null)
throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture."); throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture.");
Texture2D target = this.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));
// validate
if (!source.Bounds.Contains(sourceArea.Value)) if (!source.Bounds.Contains(sourceArea.Value))
throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); 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 // get source data
int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height;
Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount); Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount);
source.GetData(0, sourceArea, sourceData, 0, pixelCount); source.GetData(0, sourceArea, sourceData, 0, pixelCount);
// merge data in overlay mode // apply
if (patchMode == PatchMode.Overlay) this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
{
// get target data
Color[] targetData = GC.AllocateUninitializedArray<Color>(pixelCount);
target.GetData(0, targetArea, targetData, 0, pixelCount);
// merge pixels
for (int i = 0; i < sourceData.Length; i++)
{
Color above = sourceData[i];
Color below = targetData[i];
// shortcut transparency
if (above.A < MinOpacity)
{
sourceData[i] = below;
continue;
}
if (below.A < MinOpacity)
{
sourceData[i] = above;
continue;
}
// 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
);
}
}
// patch target texture
target.SetData(0, targetArea, sourceData, 0, pixelCount);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -110,5 +99,85 @@ namespace StardewModdingAPI.Framework.Content
this.PatchImage(original); this.PatchImage(original);
return true; 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[] mergedData = GC.AllocateUninitializedArray<Color>(pixelCount);
target.GetData(0, targetArea, mergedData, 0, pixelCount);
// merge pixels
for (int i = 0; i < pixelCount; i++)
{
Color above = sourceData[i];
Color below = mergedData[i];
// shortcut transparency
if (above.A < MinOpacity)
continue;
if (below.A < MinOpacity)
mergedData[i] = above;
// merge pixels
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)
);
}
}
target.SetData(0, targetArea, mergedData, 0, pixelCount);
}
else
target.SetData(0, targetArea, sourceData, 0, pixelCount);
}
} }
} }

View File

@ -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;
}

View File

@ -9,6 +9,7 @@ using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Events; using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Deprecations; using StardewModdingAPI.Framework.Deprecations;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Internal; using StardewModdingAPI.Internal;
@ -93,6 +94,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <inheritdoc /> /// <inheritdoc />
public override T LoadExact<T>(IAssetName assetName, bool useCache) 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 // raise first-load callback
if (GameContentManager.IsFirstLoad) if (GameContentManager.IsFirstLoad)
{ {

View File

@ -8,6 +8,7 @@ using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using SkiaSharp; using SkiaSharp;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization;
@ -188,12 +189,17 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="file">The file to load.</param> /// <param name="file">The file to load.</param>
private T LoadImageFile<T>(IAssetName assetName, FileInfo file) private T LoadImageFile<T>(IAssetName assetName, FileInfo file)
{ {
// validate // validate type
bool asRawData = false;
if (typeof(T) != typeof(Texture2D)) 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 // load
if (this.UseExperimentalImageLoading) if (asRawData || this.UseExperimentalImageLoading)
{ {
// load raw data // load raw data
using FileStream stream = File.OpenRead(file.FullName); using FileStream stream = File.OpenRead(file.FullName);
@ -211,10 +217,15 @@ namespace StardewModdingAPI.Framework.ContentManagers
} }
// create texture // create texture
if (asRawData)
return (T)(object)new RawTextureData(bitmap.Width, bitmap.Height, pixels);
else
{
Texture2D texture = new(Game1.graphics.GraphicsDevice, bitmap.Width, bitmap.Height); Texture2D texture = new(Game1.graphics.GraphicsDevice, bitmap.Width, bitmap.Height);
texture.SetData(pixels); texture.SetData(pixels);
return (T)(object)texture; return (T)(object)texture;
} }
}
else else
{ {
using FileStream stream = File.OpenRead(file.FullName); using FileStream stream = File.OpenRead(file.FullName);

View File

@ -10,6 +10,16 @@ namespace StardewModdingAPI
/********* /*********
** Public methods ** Public methods
*********/ *********/
/// <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(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace);
/// <summary>Overwrite part of the image.</summary> /// <summary>Overwrite part of the image.</summary>
/// <param name="source">The image to patch into the content.</param> /// <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="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>

View File

@ -35,7 +35,7 @@ namespace StardewModdingAPI
** Public methods ** 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> /// <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="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> /// <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> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception>

View File

@ -48,7 +48,7 @@ namespace StardewModdingAPI
where TModel : class; 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> /// <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> /// <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="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> /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception>

View File

@ -12,7 +12,7 @@ namespace StardewModdingAPI
** Public methods ** 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> /// <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> /// <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="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> /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception>

View File

@ -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; }
}
}